use ratatui::layout::Position;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::tui::app::{App, InputMode};
pub const MAX_VISIBLE_ROWS: u16 = 6;
pub fn total_height(app: &App) -> u16 {
visible_rows(&app.prompt.buffer) + 2 + 1
}
fn visible_rows(buffer: &str) -> u16 {
let lines = buffer.lines().count().max(1);
let trailing = if buffer.ends_with('\n') { 1 } else { 0 };
let total = lines + trailing;
(total as u16).clamp(1, MAX_VISIBLE_ROWS)
}
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if area.height < 2 {
return;
}
let input_h = area.height.saturating_sub(1).max(3);
let input_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: input_h,
};
let hint_area = Rect {
x: area.x,
y: area.y + input_h,
width: area.width,
height: 1,
};
let mode = app.prompt.mode;
let buffer = &app.prompt.buffer;
let cursor_byte = app.prompt.cursor.min(buffer.len());
let indicator_style = indicator_style(mode);
let body_style = Style::default().fg(Color::White);
let lines: Vec<Line<'static>> = if buffer.is_empty() {
vec![Line::from(vec![
Span::styled(format!("{} ", mode.indicator()), indicator_style),
Span::styled(String::new(), body_style),
])]
} else {
buffer
.split('\n')
.enumerate()
.map(|(i, line)| {
let prefix = if i == 0 {
Span::styled(format!("{} ", mode.indicator()), indicator_style)
} else {
Span::raw(" ")
};
Line::from(vec![prefix, Span::styled(line.to_string(), body_style)])
})
.collect()
};
let input = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title(input_box_title(mode)),
);
frame.render_widget(input, input_area);
let (col, row) = cursor_visual_position(buffer, cursor_byte);
let cursor_x = input_area.x.saturating_add(1).saturating_add(2 + col);
let cursor_y = input_area.y.saturating_add(1).saturating_add(row);
let max_x = input_area.x + input_area.width.saturating_sub(2);
let max_y = input_area.y + input_area.height.saturating_sub(2);
let cx = cursor_x.min(max_x);
let cy = cursor_y.min(max_y);
frame.set_cursor_position(Position { x: cx, y: cy });
let hint =
Paragraph::new(footer_hint(mode)).style(Style::default().fg(Color::Gray).bg(Color::Reset));
frame.render_widget(hint, hint_area);
}
fn indicator_style(mode: InputMode) -> Style {
let fg = match mode {
InputMode::Prompt => Color::Cyan,
InputMode::Bash => Color::LightYellow,
InputMode::Note => Color::DarkGray,
InputMode::Command => Color::Magenta,
InputMode::AtFile => Color::Cyan,
InputMode::HistorySearch => Color::LightGreen,
};
Style::default().fg(fg).add_modifier(Modifier::BOLD)
}
fn input_box_title(mode: InputMode) -> &'static str {
match mode {
InputMode::Prompt => " Input ",
InputMode::Bash => " Bash ",
InputMode::Note => " Note ",
InputMode::Command => " Command ",
InputMode::AtFile => " @File ",
InputMode::HistorySearch => " 🔍 History Search ",
}
}
pub fn cursor_visual_position(buffer: &str, cursor: usize) -> (u16, u16) {
use unicode_width::UnicodeWidthStr;
let head = &buffer[..cursor.min(buffer.len())];
let row = head.matches('\n').count() as u16;
let line_start = head.rfind('\n').map(|i| i + 1).unwrap_or(0);
let col = UnicodeWidthStr::width(&head[line_start..]) as u16;
(col, row)
}
pub fn footer_hint(mode: InputMode) -> String {
match mode {
InputMode::Prompt => "⏎ submit shift+tab mode ↑↓ history esc clear".into(),
InputMode::Bash => "⏎ run shell shift+tab mode ↑↓ history esc clear".into(),
InputMode::Note => "⏎ save note shift+tab mode ↑↓ history esc clear".into(),
InputMode::Command => "⏎ run command tab autocomplete ↑↓ history".into(),
InputMode::AtFile => "⏎/tab confirm ↑↓ select backspace edit esc cancel".into(),
InputMode::HistorySearch => {
"⏎ confirm ↑↓ select ctrl+r next backspace edit esc cancel".into()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::AppScreen;
#[test]
fn renders_correct_indicator_per_mode() {
for (mode, ch) in [
(InputMode::Prompt, '❯'),
(InputMode::Bash, '!'),
(InputMode::Note, '#'),
(InputMode::Command, '/'),
] {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.prompt.mode = mode;
let backend = ratatui::backend::TestBackend::new(40, 6);
let mut term = ratatui::Terminal::new(backend).unwrap();
term.draw(|f| {
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 6,
};
render(f, area, &app);
})
.unwrap();
let buf = term.backend().buffer();
let row: String = (0..buf.area().width)
.map(|x| buf[(x, 1)].symbol())
.collect();
assert!(
row.contains(ch),
"mode {mode:?} indicator {ch:?} missing from row {row:?}"
);
}
}
#[test]
fn footer_hint_changes_per_mode() {
assert!(footer_hint(InputMode::Prompt).contains("submit"));
assert!(footer_hint(InputMode::Bash).contains("run shell"));
assert!(footer_hint(InputMode::Note).contains("save note"));
assert!(footer_hint(InputMode::Command).contains("run command"));
}
#[test]
fn cursor_visual_position_handles_multiline() {
let buf = "ab\ncde\nf";
assert_eq!(cursor_visual_position(buf, buf.len()), (1, 2));
assert_eq!(cursor_visual_position(buf, 3), (0, 1));
assert_eq!(cursor_visual_position(buf, 0), (0, 0));
}
#[test]
fn cursor_visual_position_counts_double_width_chars() {
let buf = "你好";
assert_eq!(buf.len(), 6);
assert_eq!(cursor_visual_position(buf, 3), (2, 0));
assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
}
#[test]
fn cursor_visual_position_mixed_ascii_and_cjk() {
let buf = "ab了";
assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
assert_eq!(cursor_visual_position(buf, 2), (2, 0));
}
#[test]
fn total_height_grows_with_lines_until_cap() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.prompt.buffer = "a".into();
let h1 = total_height(&app);
app.prompt.buffer = "a\nb\nc\nd\ne\nf\ng".into();
let h_max = total_height(&app);
assert!(h_max > h1);
assert!(h_max <= MAX_VISIBLE_ROWS + 2 + 1);
}
}