use std::cell::Cell;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthChar;
use super::{
chrome,
input::{self, TextCursor},
nav, scroll, style,
};
use crate::theme::Skin;
#[derive(Default)]
pub struct TextArea {
text: String,
cursor: TextCursor,
width: Cell<usize>,
scroll: Cell<usize>,
max_len: Option<usize>,
decor: Option<chrome::BoxDecor>,
}
impl TextArea {
pub fn new(initial: &str) -> Self {
Self {
text: initial.to_string(),
cursor: TextCursor::at_end(initial),
width: Cell::new(1),
scroll: Cell::new(0),
..Self::default()
}
}
#[must_use]
pub fn max_len(mut self, max: usize) -> Self {
self.max_len = Some(max);
self
}
#[must_use]
pub fn boxed(mut self, decor: chrome::BoxDecor) -> Self {
self.decor = Some(decor);
self
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: String) {
self.cursor = TextCursor::at_end(&text);
self.text = text;
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let width = self.width.get().max(1);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let mut chars: Vec<char> = self.text.chars().collect();
let len = chars.len();
self.cursor.pos = self.cursor.pos.min(len);
let consumed = match key.code {
KeyCode::Left => {
self.move_to(self.cursor.pos.saturating_sub(1), shift);
true
}
KeyCode::Right => {
self.move_to((self.cursor.pos + 1).min(len), shift);
true
}
KeyCode::Up => {
let to = self.vertical(&chars, width, -1);
self.move_to(to, shift);
true
}
KeyCode::Down => {
let to = self.vertical(&chars, width, 1);
self.move_to(to, shift);
true
}
KeyCode::Home => {
let (start, _) = self.row_bounds(&chars, width);
self.move_to(start, shift);
true
}
KeyCode::End => {
let (_, end) = self.row_bounds(&chars, width);
self.move_to(end, shift);
true
}
KeyCode::Char('a') if ctrl => {
self.cursor.anchor = Some(0);
self.cursor.pos = len;
true
}
KeyCode::Char('c') if ctrl => {
self.copy(&chars);
true
}
KeyCode::Char('x') if ctrl => {
self.copy(&chars);
self.delete_selection(&mut chars);
true
}
KeyCode::Char('v') if ctrl => {
self.paste(&mut chars);
true
}
KeyCode::Enter => {
self.insert(&mut chars, '\n');
true
}
KeyCode::Backspace => {
input::backspace(&mut chars, &mut self.cursor);
true
}
KeyCode::Delete => {
input::delete_forward(&mut chars, &mut self.cursor);
true
}
KeyCode::Char(ch) if !ctrl => {
self.insert(&mut chars, ch);
true
}
_ => false,
};
if consumed {
self.text = chars.into_iter().collect();
}
consumed
}
pub fn render(
&self,
frame: &mut Frame,
area: Rect,
skin: &Skin,
focused: bool,
) {
let inner = match &self.decor {
Some(decor) => {
chrome::framed_decor(frame, area, skin, decor, &self.badge())
}
None => area,
};
let palette = &skin.palette;
let base_bg = if focused {
palette.input_bg_active
} else {
palette.input_bg
};
let width = inner.width.max(1) as usize;
let height = inner.height.max(1) as usize;
self.width.set(width);
let chars: Vec<char> = self.text.chars().collect();
let rows = wrap(&chars, width);
let (caret_row, caret_col) = locate(&rows, self.cursor.pos);
let scroll = nav::keep_visible(
nav::ScrollView {
total: rows.len(),
offset: self.scroll.get(),
viewport: height,
},
caret_row,
);
self.scroll.set(scroll);
let selection = self.cursor.selection();
let cursor_style = style::bg(palette.cursor).fg(Color::Black);
let selection_style = style::bg(palette.selection);
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(scroll)
.take(height)
.map(|(row_index, &(start, end))| {
let mut spans: Vec<Span> = Vec::new();
for (offset, ch) in chars[start..end].iter().enumerate() {
let index = start + offset;
let mut cell = Style::default();
if let Some((from, to)) = selection
&& index >= from
&& index < to
{
cell = selection_style;
}
if focused && row_index == caret_row && offset == caret_col
{
cell = cursor_style;
}
spans.push(Span::styled(ch.to_string(), cell));
}
if focused && row_index == caret_row && caret_col == end - start
{
spans.push(Span::styled(" ".to_string(), cursor_style));
}
Line::from(spans)
})
.collect();
frame.render_widget(
Paragraph::new(lines).style(style::bg(base_bg)),
inner,
);
scroll::render_scrollbar(
frame,
inner,
skin,
nav::ScrollView {
total: rows.len(),
offset: scroll,
viewport: height,
},
);
}
fn badge(&self) -> String {
let count = self.text.chars().count();
match self.max_len {
Some(max) => format!("{count}/{max}"),
None => count.to_string(),
}
}
fn move_to(&mut self, pos: usize, extend: bool) {
input::move_caret(&mut self.cursor, pos, extend);
}
fn insert(&mut self, chars: &mut Vec<char>, ch: char) {
input::insert_char(chars, &mut self.cursor, ch, self.max_len);
}
fn delete_selection(&mut self, chars: &mut Vec<char>) -> bool {
input::delete_selection(chars, &mut self.cursor)
}
fn copy(&self, chars: &[char]) {
input::copy_selection(chars, &self.cursor);
}
fn paste(&mut self, chars: &mut Vec<char>) {
input::paste_multiline(chars, &mut self.cursor, self.max_len);
}
fn row_bounds(&self, chars: &[char], width: usize) -> (usize, usize) {
let rows = wrap(chars, width);
let (row, _) = locate(&rows, self.cursor.pos);
rows[row]
}
fn vertical(&self, chars: &[char], width: usize, delta: isize) -> usize {
let rows = wrap(chars, width);
let (row, col) = locate(&rows, self.cursor.pos);
let target = (row as isize + delta).clamp(0, rows.len() as isize - 1);
let (start, end) = rows[target as usize];
start + col.min(end - start)
}
}
fn wrap(chars: &[char], width: usize) -> Vec<(usize, usize)> {
let width = width.max(1);
let mut rows = Vec::new();
let mut seg_start = 0;
loop {
let seg_end = chars[seg_start..]
.iter()
.position(|&c| c == '\n')
.map_or(chars.len(), |offset| seg_start + offset);
let mut start = seg_start;
loop {
let mut end = start;
let mut used = 0usize;
while end < seg_end {
let char_width = chars[end].width().unwrap_or(0);
if end > start && used + char_width > width {
break;
}
used += char_width;
end += 1;
}
rows.push((start, end));
if end >= seg_end {
break;
}
start = end;
}
match chars[seg_start..].iter().position(|&c| c == '\n') {
Some(offset) => seg_start += offset + 1,
None => break,
}
}
if rows.is_empty() {
rows.push((0, 0));
}
rows
}
fn locate(rows: &[(usize, usize)], pos: usize) -> (usize, usize) {
for (index, &(start, end)) in rows.iter().enumerate() {
if pos < end {
return (index, pos - start);
}
if pos == end {
match rows.get(index + 1) {
Some(&(next_start, _)) if next_start == pos => {}
_ => return (index, pos - start),
}
}
}
let last = rows.len() - 1;
(last, pos - rows[last].0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_breaks_on_width_and_newlines() {
let chars: Vec<char> = "abcdef\nxy".chars().collect();
assert_eq!(wrap(&chars, 4), vec![(0, 4), (4, 6), (7, 9)]);
}
#[test]
fn empty_buffer_has_one_row() {
assert_eq!(wrap(&[], 4), vec![(0, 0)]);
}
#[test]
fn wrap_measures_display_width_of_wide_chars() {
let chars: Vec<char> = "世界a".chars().collect();
assert_eq!(wrap(&chars, 3), vec![(0, 1), (1, 3)]);
}
#[test]
fn trailing_newline_adds_empty_row() {
let chars: Vec<char> = "ab\n".chars().collect();
assert_eq!(wrap(&chars, 4), vec![(0, 2), (3, 3)]);
}
#[test]
fn enter_inserts_newline() {
let mut area = TextArea::new("ab");
area.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(area.text(), "ab\n");
}
#[test]
fn max_len_blocks_typing_past_the_limit() {
let mut area = TextArea::new("ab").max_len(3);
area.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert_eq!(area.text(), "abc");
area.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(area.text(), "abc");
}
#[test]
fn backspace_delegates_to_the_shared_edit_core() {
let mut area = TextArea::new("ab");
area.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(area.text(), "a");
}
#[test]
fn shift_left_selects_and_typing_replaces_via_the_core() {
let mut area = TextArea::new("abc");
area.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
assert_eq!(area.cursor.selection(), Some((2, 3)));
area.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(area.text(), "abX");
}
#[test]
fn ctrl_a_selects_all_then_backspace_clears() {
let mut area = TextArea::new("line 1\nline 2");
area.handle_key(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL,
));
assert_eq!(area.cursor.selection(), Some((0, 13)));
area.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(area.text(), "");
}
}