use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Widget, Wrap};
use ratatui_textarea::{TextArea, WrapMode};
use crate::widget::{RenderCtx, Renderable, hash_combine, hash_str};
#[derive(Debug, Default)]
pub struct InputArea {
textarea: TextArea<'static>,
}
impl InputArea {
#[must_use]
pub fn new() -> Self {
let mut textarea = TextArea::default();
textarea.remove_line_number();
textarea.set_cursor_line_style(Style::default());
textarea.set_wrap_mode(WrapMode::Word);
Self { textarea }
}
#[must_use]
pub fn textarea(&self) -> &TextArea<'static> {
&self.textarea
}
#[must_use]
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
&mut self.textarea
}
#[must_use]
pub fn text(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn set_text(&mut self, text: &str) {
self.textarea.clear();
if !text.is_empty() {
self.textarea.insert_str(text);
}
}
fn wrapped_height(&self, width: u16) -> u16 {
if width < 1 {
return 1;
}
let lines = self.textarea.lines();
if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
return 1;
}
let text = Text::from(lines.join("\n"));
let paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
u16::try_from(paragraph.line_count(width))
.unwrap_or(u16::MAX)
.max(1)
}
}
impl Renderable for InputArea {
fn content_hash(&self) -> u64 {
let text_hash = hash_str(&self.text());
let cursor = self.textarea.screen_cursor();
let cursor_hash = hash_combine(cursor.row as u64, cursor.col as u64);
hash_combine(text_hash, cursor_hash)
}
fn height_for(&self, width: u16, _ctx: &RenderCtx) -> u16 {
self.wrapped_height(width)
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
if area.height < 1 || area.width < 1 {
return;
}
let bg = ctx.theme().colors.background;
let fg = ctx.theme().colors.foreground;
let buf: &mut Buffer = ctx.buffer_mut();
let textarea = &mut self.textarea;
textarea.set_style(Style::default().fg(fg).bg(bg));
textarea.set_cursor_line_style(Style::default());
let to_render = textarea.clone();
to_render.render(area, buf);
let sc = textarea.screen_cursor();
let x = area
.x
.saturating_add(u16::try_from(sc.col).unwrap_or(u16::MAX));
let y = area
.y
.saturating_add(u16::try_from(sc.row).unwrap_or(u16::MAX));
ctx.set_cursor(Position { x, y });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn make_ctx<'a, 'f>(
frame: &'a mut ratatui::Frame<'f>,
theme: &'a Theme,
caps: &'a TerminalCaps,
) -> RenderCtx<'a, 'f> {
RenderCtx::new(frame, theme, caps)
}
fn render_frame<F>(width: u16, height: u16, mut f: F) -> ratatui::buffer::Buffer
where
F: FnMut(&mut RenderCtx<'_, '_>),
{
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = make_ctx(frame, &theme, &caps);
f(&mut ctx);
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn input_area_renders_text() {
let mut input = InputArea::new();
input.set_text("hello world");
let buf = render_frame(20, 3, |ctx| {
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 3,
};
input.render(area, ctx);
});
let row: String = (0..20u16)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
assert!(
row.starts_with("hello world"),
"first row should start with the typed text, got: {row:?}",
);
}
#[test]
fn input_area_hash_changes_on_text_change() {
let mut input = InputArea::new();
let h0 = input.content_hash();
input.set_text("first");
let h1 = input.content_hash();
input.set_text("second");
let h2 = input.content_hash();
assert_ne!(h0, h1, "adding text should change the hash");
assert_ne!(h1, h2, "changing text should change the hash");
}
#[test]
fn input_area_hash_changes_on_cursor_move() {
let mut input = InputArea::new();
input.set_text("abc");
input
.textarea_mut()
.move_cursor(ratatui_textarea::CursorMove::Head);
let h_before = input.content_hash();
input
.textarea_mut()
.move_cursor(ratatui_textarea::CursorMove::Forward);
let h_after = input.content_hash();
assert_ne!(
h_before, h_after,
"cursor move must change content_hash (no field cache)",
);
}
#[test]
fn input_area_sets_cursor_in_ctx() {
let mut input = InputArea::new();
input.set_text("hi");
let backend = TestBackend::new(20, 3);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = make_ctx(frame, &theme, &caps);
let area = Rect {
x: 2,
y: 1,
width: 20,
height: 3,
};
input.render(area, &mut ctx);
let slot = ctx.take_cursor_slot();
match slot {
crate::pipeline::CursorSlot::Show(pos) => {
assert!(
pos.x >= area.x && pos.x < area.x + area.width,
"cursor x {} out of rendered area x range [{}..{})",
pos.x,
area.x,
area.x + area.width,
);
assert!(
pos.y >= area.y && pos.y < area.y + area.height,
"cursor y {} out of rendered area y range [{}..{})",
pos.y,
area.y,
area.y + area.height,
);
}
other => panic!("expected CursorSlot::Show, got {other:?}"),
}
})
.unwrap();
}
#[test]
fn height_for_grows_with_wrapped_lines() {
let mut input_a = InputArea::new();
input_a.set_text("one\ntwo");
let h_wide = height_in(&input_a, 20);
assert!(h_wide >= 2, "two lines should occupy ≥2 rows, got {h_wide}");
let mut input_b = InputArea::new();
input_b.set_text("aaaaaaaaaa");
let h_narrow = height_in(&input_b, 5);
assert!(
h_narrow >= 2,
"long line should wrap at narrow width, got {h_narrow}",
);
}
fn height_in(input: &InputArea, width: u16) -> u16 {
let backend = TestBackend::new(width.max(1), 1);
let mut term = Terminal::new(backend).unwrap();
let mut h = 0_u16;
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let ctx = make_ctx(frame, &theme, &caps);
h = input.height_for(width, &ctx);
})
.unwrap();
h
}
}