use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::element::Element;
use crate::focus::FocusHandle;
use crate::input::InputEvent;
pub struct TextAreaState {
lines: Vec<String>,
line: usize,
col: usize,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
lines: vec![String::new()],
line: 0,
col: 0,
}
}
}
fn grapheme_count(s: &str) -> usize {
s.graphemes(true).count()
}
fn byte_at(s: &str, col: usize) -> usize {
s.grapheme_indices(true)
.nth(col)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
impl TextAreaState {
pub fn new() -> Self {
Self::default()
}
pub fn handle(&mut self, event: &InputEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
match event {
InputEvent::Key(k) => {
if k.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return;
}
match k.code {
KeyCode::Char(c) => self.insert_char(c),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete(),
KeyCode::Left => self.move_left(),
KeyCode::Right => self.move_right(),
KeyCode::Up => self.move_vertical(-1),
KeyCode::Down => self.move_vertical(1),
KeyCode::Home => self.col = 0,
KeyCode::End => self.col = grapheme_count(&self.lines[self.line]),
_ => {}
}
}
InputEvent::Paste(s) => self.insert_str(s),
_ => {}
}
}
fn insert_char(&mut self, c: char) {
if c.is_control() {
return;
}
let line = &mut self.lines[self.line];
let at = byte_at(line, self.col);
line.insert(at, c);
self.col = grapheme_count(&line[..at + c.len_utf8()]);
}
pub fn insert_str(&mut self, s: &str) {
let keep = |c: char| !c.is_control() || c == '\n' || c == '\t';
let s: std::borrow::Cow<'_, str> = if s.chars().all(keep) {
s.into()
} else {
s.chars().filter(|&c| keep(c)).collect::<String>().into()
};
for (i, part) in s.split('\n').enumerate() {
if i > 0 {
self.insert_newline();
}
if !part.is_empty() {
let line = &mut self.lines[self.line];
let at = byte_at(line, self.col);
line.insert_str(at, part);
self.col = grapheme_count(&line[..at + part.len()]);
}
}
}
pub fn insert_newline(&mut self) {
let line = &mut self.lines[self.line];
let at = byte_at(line, self.col);
let rest = line.split_off(at);
self.lines.insert(self.line + 1, rest);
self.line += 1;
self.col = 0;
}
fn backspace(&mut self) {
if self.col > 0 {
let line = &mut self.lines[self.line];
let start = byte_at(line, self.col - 1);
let end = byte_at(line, self.col);
line.replace_range(start..end, "");
self.col -= 1;
} else if self.line > 0 {
let removed = self.lines.remove(self.line);
self.line -= 1;
self.col = grapheme_count(&self.lines[self.line]);
self.lines[self.line].push_str(&removed);
}
}
fn delete(&mut self) {
let count = grapheme_count(&self.lines[self.line]);
if self.col < count {
let line = &mut self.lines[self.line];
let start = byte_at(line, self.col);
let end = byte_at(line, self.col + 1);
line.replace_range(start..end, "");
} else if self.line + 1 < self.lines.len() {
let next = self.lines.remove(self.line + 1);
self.lines[self.line].push_str(&next);
}
}
fn move_left(&mut self) {
if self.col > 0 {
self.col -= 1;
} else if self.line > 0 {
self.line -= 1;
self.col = grapheme_count(&self.lines[self.line]);
}
}
fn move_right(&mut self) {
if self.col < grapheme_count(&self.lines[self.line]) {
self.col += 1;
} else if self.line + 1 < self.lines.len() {
self.line += 1;
self.col = 0;
}
}
fn move_vertical(&mut self, delta: isize) {
let target = self.line.saturating_add_signed(delta);
if target < self.lines.len() {
self.line = target;
self.col = self.col.min(grapheme_count(&self.lines[self.line]));
}
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn set_text(&mut self, text: &str) {
self.lines = text.split('\n').map(String::from).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.line = self.lines.len() - 1;
self.col = grapheme_count(&self.lines[self.line]);
}
pub fn take_text(&mut self) -> String {
let text = self.text();
*self = Self::default();
text
}
pub fn is_blank(&self) -> bool {
self.lines.iter().all(|l| l.trim().is_empty())
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn cursor(&self) -> (usize, usize) {
(self.line, self.col)
}
fn cursor_display_col(&self) -> u16 {
let line = &self.lines[self.line];
let at = byte_at(line, self.col);
line[..at].width() as u16
}
}
pub struct TextArea<'a> {
state: &'a TextAreaState,
placeholder: String,
style: Style,
placeholder_style: Style,
focus: Option<FocusHandle>,
max_height: u16,
wrap: bool,
}
pub fn text_area(state: &TextAreaState) -> TextArea<'_> {
TextArea {
state,
placeholder: String::new(),
style: Style::default(),
placeholder_style: Style::default(),
focus: None,
max_height: u16::MAX,
wrap: true,
}
}
impl<'a> TextArea<'a> {
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn placeholder_style(mut self, style: Style) -> Self {
self.placeholder_style = style;
self
}
pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
self.focus = Some(focus.clone());
self
}
pub fn max_height(mut self, rows: u16) -> Self {
self.max_height = rows.max(1);
self
}
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
fn visual_cursor(&self, width: u16) -> (usize, u16) {
if !self.wrap {
return (
self.state.line,
self.state.cursor_display_col().min(width.saturating_sub(1)),
);
}
let rows_above: usize = self.state.lines[..self.state.line]
.iter()
.map(|l| wrap_line(l, width).len())
.sum();
let line = &self.state.lines[self.state.line];
let segments = wrap_line(line, width);
let offset = byte_at(line, self.state.col);
let mut segment = segments.len() - 1;
for (i, &(_start, end)) in segments.iter().enumerate() {
if offset < end {
segment = i;
break;
}
}
let (start, _end) = segments[segment];
let col = (line[start..offset].width() as u16).min(width.saturating_sub(1));
(rows_above + segment, col)
}
fn window_start(&self, width: u16, height: u16) -> usize {
let (cursor_row, _) = self.visual_cursor(width);
cursor_row.saturating_sub(height.saturating_sub(1) as usize)
}
}
impl Element for TextArea<'_> {
fn height(&self, width: u16) -> u16 {
if width == 0 {
return 0;
}
let rows = if self.wrap {
self.state
.lines
.iter()
.map(|l| wrap_line(l, width).len())
.sum::<usize>()
} else {
self.state.line_count()
};
(rows.min(u16::MAX as usize) as u16).clamp(1, self.max_height)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
if self.state.is_blank() && !self.placeholder.is_empty() {
buf.set_stringn(
area.x,
area.y,
&self.placeholder,
area.width as usize,
self.placeholder_style,
);
return;
}
let first = self.window_start(area.width, area.height);
let mut visual_row = 0usize;
let mut y = 0u16;
'lines: for line in &self.state.lines {
let segments = if self.wrap {
wrap_line(line, area.width)
} else {
vec![(0, line.len())]
};
for &(start, end) in &segments {
if visual_row >= first {
if y >= area.height {
break 'lines;
}
buf.set_stringn(
area.x,
area.y + y,
&line[start..end],
area.width as usize,
self.style,
);
y += 1;
}
visual_row += 1;
}
}
}
fn cursor(&self, area: Rect) -> Option<(u16, u16)> {
let focused = self.focus.as_ref().is_some_and(FocusHandle::is_focused);
if !focused {
return None;
}
let (cursor_row, col) = self.visual_cursor(area.width);
let first = self.window_start(area.width, area.height);
Some((col, (cursor_row - first) as u16))
}
}
fn wrap_line(line: &str, width: u16) -> Vec<(usize, usize)> {
if width == 0 {
return vec![(0, line.len())];
}
let mut segments = Vec::new();
let mut start = 0usize;
let mut used = 0u16;
for (idx, grapheme) in line.grapheme_indices(true) {
let gw = grapheme.width() as u16;
if used + gw > width && used > 0 {
segments.push((start, idx));
start = idx;
used = 0;
}
used += gw;
}
segments.push((start, line.len()));
segments
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::focus::Focus;
fn press(state: &mut TextAreaState, code: KeyCode) {
state.handle(&InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)));
}
fn type_str(state: &mut TextAreaState, s: &str) {
for c in s.chars() {
press(state, KeyCode::Char(c));
}
}
#[test]
fn typing_and_text_roundtrip() {
let mut s = TextAreaState::new();
type_str(&mut s, "hello");
assert_eq!(s.text(), "hello");
assert_eq!(s.cursor(), (0, 5));
}
#[test]
fn newline_split_and_backspace_join() {
let mut s = TextAreaState::new();
type_str(&mut s, "ab");
press(&mut s, KeyCode::Left);
s.insert_newline();
assert_eq!(s.text(), "a\nb");
assert_eq!(s.cursor(), (1, 0));
press(&mut s, KeyCode::Backspace);
assert_eq!(s.text(), "ab");
assert_eq!(s.cursor(), (0, 1));
}
#[test]
fn backspace_removes_whole_grapheme() {
let mut s = TextAreaState::new();
s.insert_str("a👩👩👦b");
assert_eq!(s.cursor(), (0, 3));
press(&mut s, KeyCode::Left); press(&mut s, KeyCode::Backspace); assert_eq!(s.text(), "ab");
assert_eq!(s.cursor(), (0, 1));
}
#[test]
fn arrows_navigate_graphemes_and_lines() {
let mut s = TextAreaState::new();
s.insert_str("日本\nx");
assert_eq!(s.cursor(), (1, 1));
press(&mut s, KeyCode::Up);
assert_eq!(s.cursor(), (0, 1));
press(&mut s, KeyCode::End);
assert_eq!(s.cursor(), (0, 2));
press(&mut s, KeyCode::Right); assert_eq!(s.cursor(), (1, 0));
}
#[test]
fn cursor_display_col_is_width_aware() {
let mut s = TextAreaState::new();
s.insert_str("日本x");
press(&mut s, KeyCode::Home);
press(&mut s, KeyCode::Right);
press(&mut s, KeyCode::Right);
assert_eq!(s.cursor_display_col(), 4);
}
#[test]
fn ctrl_chars_are_ignored() {
let mut s = TextAreaState::new();
s.handle(&InputEvent::Key(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL,
)));
assert!(s.is_blank());
}
#[test]
fn paste_with_newlines() {
let mut s = TextAreaState::new();
s.handle(&InputEvent::Paste("one\ntwo".into()));
assert_eq!(s.text(), "one\ntwo");
assert_eq!(s.cursor(), (1, 3));
}
#[test]
fn take_text_resets() {
let mut s = TextAreaState::new();
type_str(&mut s, "hi");
assert_eq!(s.take_text(), "hi");
assert!(s.is_blank());
assert_eq!(s.cursor(), (0, 0));
}
#[test]
fn max_height_window_follows_cursor() {
let mut s = TextAreaState::new();
s.insert_str("l0\nl1\nl2\nl3");
let ta = text_area(&s).max_height(2);
assert_eq!(Element::height(&ta, 10), 2);
let area = Rect::new(0, 0, 10, 2);
let mut buf = Buffer::empty(area);
ta.render(area, &mut buf);
assert_eq!(buf[(1, 0)].symbol(), "2");
assert_eq!(buf[(1, 1)].symbol(), "3");
}
#[test]
fn cursor_reported_only_when_focused() {
use crate::focus::Focus;
let mut s = TextAreaState::new();
type_str(&mut s, "hi");
let focus = Focus::new();
let handle = focus.handle();
let area = Rect::new(0, 0, 10, 1);
assert_eq!(text_area(&s).cursor(area), None);
assert_eq!(text_area(&s).track_focus(&handle).cursor(area), None);
handle.focus();
assert_eq!(
text_area(&s).track_focus(&handle).cursor(area),
Some((2, 0))
);
}
fn rendered(el: &TextArea<'_>, width: u16) -> Vec<String> {
let height = Element::height(el, width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
(0..height)
.map(|y| {
let mut line: String = (0..width).map(|x| buf[(x, y)].symbol()).collect();
while line.ends_with(' ') {
line.pop();
}
line
})
.collect()
}
#[test]
fn wrap_line_splits_at_width() {
assert_eq!(wrap_line("abcdef", 4), vec![(0, 4), (4, 6)]);
assert_eq!(wrap_line("", 4), vec![(0, 0)]);
assert_eq!(wrap_line("ab", 4), vec![(0, 2)]);
}
#[test]
fn wrap_line_respects_wide_graphemes() {
let s = "日本語";
let segs = wrap_line(s, 4);
assert_eq!(segs.len(), 2);
assert_eq!(&s[segs[0].0..segs[0].1], "日本");
assert_eq!(&s[segs[1].0..segs[1].1], "語");
assert_eq!(wrap_line(s, 3).len(), 3);
}
#[test]
fn long_line_wraps_in_render_and_height() {
let mut s = TextAreaState::new();
s.insert_str("abcdefgh");
let ta = text_area(&s);
assert_eq!(Element::height(&ta, 4), 2);
assert_eq!(rendered(&ta, 4), vec!["abcd", "efgh"]);
}
#[test]
fn cursor_maps_into_wrapped_rows() {
let mut s = TextAreaState::new();
s.insert_str("abcdefgh");
press(&mut s, KeyCode::Home);
for _ in 0..5 {
press(&mut s, KeyCode::Right);
}
let focus = Focus::new();
let handle = focus.handle();
handle.focus();
let ta = text_area(&s).track_focus(&handle);
let area = Rect::new(0, 0, 4, Element::height(&ta, 4));
assert_eq!(ta.cursor(area), Some((1, 1)));
}
#[test]
fn cursor_at_exact_row_boundary_lands_on_next_row() {
let mut s = TextAreaState::new();
s.insert_str("abcdefgh");
press(&mut s, KeyCode::Home);
for _ in 0..4 {
press(&mut s, KeyCode::Right);
}
let focus = Focus::new();
let handle = focus.handle();
handle.focus();
let ta = text_area(&s).track_focus(&handle);
let area = Rect::new(0, 0, 4, Element::height(&ta, 4));
assert_eq!(ta.cursor(area), Some((0, 1)));
}
#[test]
fn cursor_at_end_of_exactly_full_line_clamps() {
let mut s = TextAreaState::new();
s.insert_str("abcd");
let focus = Focus::new();
let handle = focus.handle();
handle.focus();
let ta = text_area(&s).track_focus(&handle);
let area = Rect::new(0, 0, 4, 1);
assert_eq!(ta.cursor(area), Some((3, 0)));
}
#[test]
fn window_follows_visual_cursor_through_wrapped_content() {
let mut s = TextAreaState::new();
s.insert_str("aaaabbbbccccdddd\nend");
let ta = text_area(&s).max_height(2);
assert_eq!(Element::height(&ta, 4), 2);
assert_eq!(rendered(&ta, 4), vec!["dddd", "end"]);
}
#[test]
fn no_wrap_mode_truncates() {
let mut s = TextAreaState::new();
s.insert_str("abcdefgh");
let ta = text_area(&s).wrap(false);
assert_eq!(Element::height(&ta, 4), 1);
assert_eq!(rendered(&ta, 4), vec!["abcd"]);
}
}
#[cfg(test)]
mod combining_tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn press(state: &mut TextAreaState, c: char) {
state.handle(&crate::InputEvent::Key(KeyEvent::new(
KeyCode::Char(c),
KeyModifiers::NONE,
)));
}
#[test]
fn combining_mark_merges_without_advancing_cursor() {
let mut state = TextAreaState::new();
press(&mut state, 'e');
assert_eq!(state.cursor(), (0, 1));
press(&mut state, '\u{0301}');
assert_eq!(state.cursor(), (0, 1), "combining mark must not advance");
state.handle(&crate::InputEvent::Key(KeyEvent::new(
KeyCode::Backspace,
KeyModifiers::NONE,
)));
assert!(state.is_blank());
}
#[test]
fn paste_starting_with_combining_mark_keeps_cursor_in_bounds() {
let mut state = TextAreaState::new();
press(&mut state, 'e');
state.handle(&InputEvent::Paste("\u{301}x".into()));
let (line, col) = state.cursor();
assert_eq!(line, 0);
assert_eq!(state.text(), "e\u{301}x");
assert_eq!(col, 2, "cursor past the end of the line");
}
}