use crate::cursor::{Cursor, Mode as CursorMode, blink_cmd};
use crate::key::{Binding, matches};
use crate::runeutil::Sanitizer;
use crate::viewport::Viewport;
use bubbletea::{Cmd, KeyMsg, Message, Model};
use lipgloss::Style;
use unicode_width::UnicodeWidthStr;
const MIN_HEIGHT: usize = 1;
const DEFAULT_HEIGHT: usize = 6;
const DEFAULT_WIDTH: usize = 40;
const DEFAULT_MAX_HEIGHT: usize = 99;
const DEFAULT_MAX_WIDTH: usize = 500;
const MAX_LINES: usize = 10000;
#[derive(Debug, Clone)]
pub struct KeyMap {
pub character_forward: Binding,
pub character_backward: Binding,
pub delete_after_cursor: Binding,
pub delete_before_cursor: Binding,
pub delete_character_backward: Binding,
pub delete_character_forward: Binding,
pub delete_word_backward: Binding,
pub delete_word_forward: Binding,
pub insert_newline: Binding,
pub line_end: Binding,
pub line_next: Binding,
pub line_previous: Binding,
pub line_start: Binding,
pub paste: Binding,
pub word_backward: Binding,
pub word_forward: Binding,
pub input_begin: Binding,
pub input_end: Binding,
pub uppercase_word_forward: Binding,
pub lowercase_word_forward: Binding,
pub capitalize_word_forward: Binding,
pub transpose_character_backward: Binding,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
character_forward: Binding::new()
.keys(&["right", "ctrl+f"])
.help("right", "character forward"),
character_backward: Binding::new()
.keys(&["left", "ctrl+b"])
.help("left", "character backward"),
word_forward: Binding::new()
.keys(&["alt+right", "alt+f"])
.help("alt+right", "word forward"),
word_backward: Binding::new()
.keys(&["alt+left", "alt+b"])
.help("alt+left", "word backward"),
line_next: Binding::new()
.keys(&["down", "ctrl+n"])
.help("down", "next line"),
line_previous: Binding::new()
.keys(&["up", "ctrl+p"])
.help("up", "previous line"),
delete_word_backward: Binding::new()
.keys(&["alt+backspace", "ctrl+w"])
.help("alt+backspace", "delete word backward"),
delete_word_forward: Binding::new()
.keys(&["alt+delete", "alt+d"])
.help("alt+delete", "delete word forward"),
delete_after_cursor: Binding::new()
.keys(&["ctrl+k"])
.help("ctrl+k", "delete after cursor"),
delete_before_cursor: Binding::new()
.keys(&["ctrl+u"])
.help("ctrl+u", "delete before cursor"),
insert_newline: Binding::new()
.keys(&["enter", "ctrl+m"])
.help("enter", "insert newline"),
delete_character_backward: Binding::new()
.keys(&["backspace", "ctrl+h"])
.help("backspace", "delete character backward"),
delete_character_forward: Binding::new()
.keys(&["delete", "ctrl+d"])
.help("delete", "delete character forward"),
line_start: Binding::new()
.keys(&["home", "ctrl+a"])
.help("home", "line start"),
line_end: Binding::new()
.keys(&["end", "ctrl+e"])
.help("end", "line end"),
paste: Binding::new().keys(&["ctrl+v"]).help("ctrl+v", "paste"),
input_begin: Binding::new()
.keys(&["alt+<", "ctrl+home"])
.help("alt+<", "input begin"),
input_end: Binding::new()
.keys(&["alt+>", "ctrl+end"])
.help("alt+>", "input end"),
capitalize_word_forward: Binding::new()
.keys(&["alt+c"])
.help("alt+c", "capitalize word forward"),
lowercase_word_forward: Binding::new()
.keys(&["alt+l"])
.help("alt+l", "lowercase word forward"),
uppercase_word_forward: Binding::new()
.keys(&["alt+u"])
.help("alt+u", "uppercase word forward"),
transpose_character_backward: Binding::new()
.keys(&["ctrl+t"])
.help("ctrl+t", "transpose character backward"),
}
}
}
#[derive(Debug, Clone)]
pub struct Styles {
pub base: Style,
pub cursor_line: Style,
pub cursor_line_number: Style,
pub end_of_buffer: Style,
pub line_number: Style,
pub placeholder: Style,
pub prompt: Style,
pub text: Style,
}
impl Default for Styles {
fn default() -> Self {
Self {
base: Style::new(),
cursor_line: Style::new(),
cursor_line_number: Style::new(),
end_of_buffer: Style::new(),
line_number: Style::new(),
placeholder: Style::new(),
prompt: Style::new(),
text: Style::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct PasteMsg(pub String);
#[derive(Debug, Clone)]
pub struct PasteErrMsg(pub String);
#[derive(Debug, Clone)]
pub struct TextArea {
pub err: Option<String>,
pub prompt: String,
pub placeholder: String,
pub show_line_numbers: bool,
pub end_of_buffer_character: char,
pub key_map: KeyMap,
pub focused_style: Styles,
pub blurred_style: Styles,
pub cursor: Cursor,
pub char_limit: usize,
pub max_height: usize,
pub max_width: usize,
use_focused_style: bool,
prompt_width: usize,
width: usize,
height: usize,
value: Vec<Vec<char>>,
focus: bool,
col: usize,
row: usize,
last_char_offset: usize,
viewport: Viewport,
sanitizer: Sanitizer,
}
impl Default for TextArea {
fn default() -> Self {
Self::new()
}
}
impl TextArea {
#[must_use]
pub fn new() -> Self {
let viewport = Viewport::new(0, 0);
let mut ta = Self {
err: None,
prompt: "β ".to_string(),
placeholder: String::new(),
show_line_numbers: true,
end_of_buffer_character: ' ',
key_map: KeyMap::default(),
focused_style: Styles::default(),
blurred_style: Styles::default(),
use_focused_style: false,
cursor: Cursor::new(),
char_limit: 0,
max_height: DEFAULT_MAX_HEIGHT,
max_width: DEFAULT_MAX_WIDTH,
prompt_width: 2, width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
value: vec![Vec::new()],
focus: false,
col: 0,
row: 0,
last_char_offset: 0,
viewport,
sanitizer: Sanitizer::new(),
};
ta.set_height(DEFAULT_HEIGHT);
ta.set_width(DEFAULT_WIDTH);
ta
}
pub fn set_value(&mut self, s: &str) {
self.reset();
self.insert_string(s);
}
pub fn insert_string(&mut self, s: &str) {
self.insert_runes_from_user_input(&s.chars().collect::<Vec<_>>());
}
pub fn insert_rune(&mut self, r: char) {
self.insert_runes_from_user_input(&[r]);
}
fn insert_runes_from_user_input(&mut self, runes: &[char]) {
let runes = self.sanitizer.sanitize(runes);
let runes = if self.char_limit > 0 {
let current_len = self.length();
let avail = self.char_limit.saturating_sub(current_len);
if avail == 0 {
return;
}
if runes.len() > avail {
runes[..avail].to_vec()
} else {
runes
}
} else {
runes
};
let mut lines: Vec<Vec<char>> = Vec::new();
let mut current_line = Vec::new();
for c in &runes {
if *c == '\n' {
lines.push(current_line);
current_line = Vec::new();
} else {
current_line.push(*c);
}
}
lines.push(current_line);
if MAX_LINES > 0 && self.value.len() + lines.len() - 1 > MAX_LINES {
let allowed = MAX_LINES.saturating_sub(self.value.len()) + 1;
lines.truncate(allowed);
}
if lines.is_empty() {
return;
}
let tail: Vec<char> = self.value[self.row][self.col..].to_vec();
self.value[self.row].truncate(self.col);
self.value[self.row].extend_from_slice(&lines[0]);
self.col += lines[0].len();
if lines.len() > 1 {
for line in lines.into_iter().skip(1) {
self.row += 1;
self.value.insert(self.row, line.clone());
self.col = line.len();
}
}
self.value[self.row].extend_from_slice(&tail);
self.set_cursor_col(self.col);
}
#[must_use]
pub fn value(&self) -> String {
self.value
.iter()
.map(|line| line.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn length(&self) -> usize {
let char_count: usize = self.value.iter().map(|line| line.len()).sum();
char_count + self.value.len().saturating_sub(1)
}
#[must_use]
pub fn line_count(&self) -> usize {
self.value.len()
}
#[must_use]
pub fn line(&self) -> usize {
self.row
}
#[must_use]
pub fn cursor_col(&self) -> usize {
self.col
}
#[must_use]
pub fn cursor_pos(&self) -> (usize, usize) {
(self.row, self.col)
}
#[must_use]
pub fn cursor_byte_offset(&self) -> usize {
if self.value.is_empty() {
return 0;
}
let row = self.row.min(self.value.len().saturating_sub(1));
let col = self.col.min(self.value[row].len());
let mut offset = 0usize;
for line in &self.value[..row] {
offset = offset.saturating_add(line.iter().map(|c| c.len_utf8()).sum::<usize>());
offset = offset.saturating_add(1); }
offset.saturating_add(
self.value[row][..col]
.iter()
.map(|c| c.len_utf8())
.sum::<usize>(),
)
}
pub fn set_cursor_byte_offset(&mut self, offset: usize) {
if self.value.is_empty() {
self.value = vec![Vec::new()];
}
fn col_for_byte_offset(line: &[char], byte_offset: usize) -> usize {
let mut col = 0usize;
let mut used = 0usize;
for c in line {
let len = c.len_utf8();
if used.saturating_add(len) > byte_offset {
break;
}
used = used.saturating_add(len);
col = col.saturating_add(1);
}
col
}
let mut remaining = offset;
for (idx, line) in self.value.iter().enumerate() {
let line_bytes = line.iter().map(|c| c.len_utf8()).sum::<usize>();
if remaining <= line_bytes {
self.row = idx;
let col = col_for_byte_offset(line, remaining);
self.set_cursor_col(col);
return;
}
remaining = remaining.saturating_sub(line_bytes);
if idx + 1 < self.value.len() {
if remaining == 0 {
self.row = idx;
self.set_cursor_col(line.len());
return;
}
remaining = remaining.saturating_sub(1);
if remaining == 0 {
self.row = idx + 1;
self.set_cursor_col(0);
return;
}
}
}
self.row = self.value.len().saturating_sub(1);
let last_len = self.value[self.row].len();
self.set_cursor_col(last_len);
}
pub fn cursor_down(&mut self) {
if self.row < self.value.len() - 1 {
self.row += 1;
self.col = self.col.min(self.value[self.row].len());
}
}
pub fn cursor_up(&mut self) {
if self.row > 0 {
self.row -= 1;
self.col = self.col.min(self.value[self.row].len());
}
}
pub fn set_cursor_col(&mut self, col: usize) {
self.col = col.min(self.value[self.row].len());
self.last_char_offset = 0;
}
pub fn cursor_start(&mut self) {
self.set_cursor_col(0);
}
pub fn cursor_end(&mut self) {
self.set_cursor_col(self.value[self.row].len());
}
pub fn cursor_left(&mut self) {
self.character_left(false);
}
pub fn cursor_right(&mut self) {
self.character_right();
}
#[must_use]
pub fn focused(&self) -> bool {
self.focus
}
pub fn focus(&mut self) -> Option<Cmd> {
self.focus = true;
self.use_focused_style = true;
self.cursor.focus()
}
pub fn blur(&mut self) {
self.focus = false;
self.use_focused_style = false;
self.cursor.blur();
}
pub fn reset(&mut self) {
self.value = vec![Vec::new()];
self.col = 0;
self.row = 0;
self.viewport.goto_top();
self.set_cursor_col(0);
}
fn current_style(&self) -> &Styles {
if self.use_focused_style {
&self.focused_style
} else {
&self.blurred_style
}
}
fn delete_before_cursor(&mut self) {
self.value[self.row] = self.value[self.row][self.col..].to_vec();
self.set_cursor_col(0);
}
fn delete_after_cursor(&mut self) {
self.value[self.row].truncate(self.col);
self.set_cursor_col(self.value[self.row].len());
}
fn transpose_left(&mut self) {
let len = self.value[self.row].len();
if self.col == 0 || len < 2 {
return;
}
if self.col >= len {
self.set_cursor_col(len - 1);
}
self.value[self.row].swap(self.col - 1, self.col);
if self.col < self.value[self.row].len() {
self.set_cursor_col(self.col + 1);
}
}
fn delete_word_left(&mut self) {
if self.col == 0 || self.value[self.row].is_empty() {
return;
}
let old_col = self.col;
self.set_cursor_col(self.col.saturating_sub(1));
while self.col > 0
&& self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
self.set_cursor_col(self.col.saturating_sub(1));
}
while self.col > 0 {
if !self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
self.set_cursor_col(self.col.saturating_sub(1));
} else {
if self.col > 0 {
self.set_cursor_col(self.col + 1);
}
break;
}
}
let mut new_line = self.value[self.row][..self.col].to_vec();
if old_col <= self.value[self.row].len() {
new_line.extend_from_slice(&self.value[self.row][old_col..]);
}
self.value[self.row] = new_line;
}
fn delete_word_right(&mut self) {
if self.col >= self.value[self.row].len() || self.value[self.row].is_empty() {
return;
}
let old_col = self.col;
while self.col < self.value[self.row].len()
&& self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
self.set_cursor_col(self.col + 1);
}
while self.col < self.value[self.row].len() {
if !self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
self.set_cursor_col(self.col + 1);
} else {
break;
}
}
let mut new_line = self.value[self.row][..old_col].to_vec();
if self.col <= self.value[self.row].len() {
new_line.extend_from_slice(&self.value[self.row][self.col..]);
}
self.value[self.row] = new_line;
self.set_cursor_col(old_col);
}
fn character_right(&mut self) {
if self.col < self.value[self.row].len() {
self.set_cursor_col(self.col + 1);
} else if self.row < self.value.len() - 1 {
self.row += 1;
self.cursor_start();
}
}
fn character_left(&mut self, inside_line: bool) {
if self.col == 0 && self.row > 0 {
self.row -= 1;
self.cursor_end();
if !inside_line {
return;
}
}
if self.col > 0 {
self.set_cursor_col(self.col - 1);
}
}
fn word_left(&mut self) {
loop {
self.character_left(true);
if self.col < self.value[self.row].len()
&& !self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
break;
}
if self.col == 0 && self.row == 0 {
break;
}
}
while self.col > 0 {
if self.value[self.row]
.get(self.col - 1)
.is_some_and(|c| c.is_whitespace())
{
break;
}
self.set_cursor_col(self.col - 1);
}
}
fn word_right(&mut self) {
while self.col >= self.value[self.row].len()
|| self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
if self.row == self.value.len() - 1 && self.col == self.value[self.row].len() {
break;
}
self.character_right();
}
while self.col < self.value[self.row].len() {
if self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
break;
}
self.set_cursor_col(self.col + 1);
}
}
fn uppercase_right(&mut self) {
self.do_word_right(|line, i| {
line[i] = line[i].to_uppercase().next().unwrap_or(line[i]);
});
}
fn lowercase_right(&mut self) {
self.do_word_right(|line, i| {
line[i] = line[i].to_lowercase().next().unwrap_or(line[i]);
});
}
fn capitalize_right(&mut self) {
let mut char_idx = 0;
self.do_word_right(|line, i| {
if char_idx == 0 {
line[i] = line[i].to_uppercase().next().unwrap_or(line[i]);
}
char_idx += 1;
});
}
fn do_word_right<F>(&mut self, mut f: F)
where
F: FnMut(&mut Vec<char>, usize),
{
while self.col >= self.value[self.row].len()
|| self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
if self.row == self.value.len() - 1 && self.col == self.value[self.row].len() {
break;
}
self.character_right();
}
while self.col < self.value[self.row].len() {
if self.value[self.row]
.get(self.col)
.is_some_and(|c| c.is_whitespace())
{
break;
}
f(&mut self.value[self.row], self.col);
self.set_cursor_col(self.col + 1);
}
}
fn move_to_begin(&mut self) {
self.row = 0;
self.set_cursor_col(0);
}
fn move_to_end(&mut self) {
self.row = self.value.len().saturating_sub(1);
self.set_cursor_col(self.value[self.row].len());
}
pub fn set_width(&mut self, w: usize) {
self.prompt_width = UnicodeWidthStr::width(self.prompt.as_str());
let reserved_outer = 0; let mut reserved_inner = self.prompt_width;
if self.show_line_numbers {
reserved_inner += 4; }
let min_width = reserved_inner + reserved_outer + 1;
let mut input_width = w.max(min_width);
if self.max_width > 0 {
input_width = input_width.min(self.max_width);
}
self.viewport.width = input_width.saturating_sub(reserved_outer);
self.width = input_width
.saturating_sub(reserved_outer)
.saturating_sub(reserved_inner);
}
#[must_use]
pub fn width(&self) -> usize {
self.width
}
pub fn set_height(&mut self, h: usize) {
if self.max_height > 0 {
self.height = h.clamp(MIN_HEIGHT, self.max_height);
self.viewport.height = h.clamp(MIN_HEIGHT, self.max_height);
} else {
self.height = h.max(MIN_HEIGHT);
self.viewport.height = h.max(MIN_HEIGHT);
}
}
#[must_use]
pub fn height(&self) -> usize {
self.height
}
fn merge_line_below(&mut self, row: usize) {
if row >= self.value.len() - 1 {
return;
}
let below = self.value.remove(row + 1);
self.value[row].extend(below);
}
fn merge_line_above(&mut self, row: usize) {
if row == 0 {
return;
}
self.col = self.value[row - 1].len();
let current = self.value.remove(row);
self.value[row - 1].extend(current);
self.row -= 1;
}
fn split_line(&mut self, row: usize, col: usize) {
let tail = self.value[row][col..].to_vec();
self.value[row].truncate(col);
self.value.insert(row + 1, tail);
self.col = 0;
self.row += 1;
}
fn reposition_view(&mut self) {
let minimum = self.viewport.y_offset();
let maximum = minimum + self.viewport.height.saturating_sub(1);
if self.row < minimum {
self.viewport.scroll_up(minimum - self.row);
} else if self.row > maximum {
self.viewport.scroll_down(self.row - maximum);
}
}
pub fn update(&mut self, msg: Message) -> Option<Cmd> {
if !self.focus {
self.cursor.blur();
return None;
}
let old_row = self.row;
let old_col = self.col;
if let Some(paste) = msg.downcast_ref::<PasteMsg>() {
self.insert_runes_from_user_input(&paste.0.chars().collect::<Vec<_>>());
}
if let Some(paste_err) = msg.downcast_ref::<PasteErrMsg>() {
self.err = Some(paste_err.0.clone());
}
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.delete_after_cursor]) {
self.col = self.col.min(self.value[self.row].len());
if self.col >= self.value[self.row].len() {
self.merge_line_below(self.row);
} else {
self.delete_after_cursor();
}
} else if matches(&key_str, &[&self.key_map.delete_before_cursor]) {
self.col = self.col.min(self.value[self.row].len());
if self.col == 0 {
self.merge_line_above(self.row);
} else {
self.delete_before_cursor();
}
} else if matches(&key_str, &[&self.key_map.delete_character_backward]) {
self.col = self.col.min(self.value[self.row].len());
if self.col == 0 {
self.merge_line_above(self.row);
} else if !self.value[self.row].is_empty() {
self.value[self.row].remove(self.col - 1);
self.set_cursor_col(self.col.saturating_sub(1));
}
} else if matches(&key_str, &[&self.key_map.delete_character_forward]) {
if !self.value[self.row].is_empty() && self.col < self.value[self.row].len() {
self.value[self.row].remove(self.col);
}
if self.col >= self.value[self.row].len() {
self.merge_line_below(self.row);
}
} else if matches(&key_str, &[&self.key_map.delete_word_backward]) {
if self.col == 0 {
self.merge_line_above(self.row);
} else {
self.delete_word_left();
}
} else if matches(&key_str, &[&self.key_map.delete_word_forward]) {
self.col = self.col.min(self.value[self.row].len());
if self.col >= self.value[self.row].len() {
self.merge_line_below(self.row);
} else {
self.delete_word_right();
}
} else if matches(&key_str, &[&self.key_map.insert_newline]) {
if self.max_height == 0 || self.value.len() < self.max_height {
self.col = self.col.min(self.value[self.row].len());
self.split_line(self.row, self.col);
}
} else if matches(&key_str, &[&self.key_map.line_end]) {
self.cursor_end();
} else if matches(&key_str, &[&self.key_map.line_start]) {
self.cursor_start();
} else if matches(&key_str, &[&self.key_map.character_forward]) {
self.character_right();
} else if matches(&key_str, &[&self.key_map.line_next]) {
self.cursor_down();
} else if matches(&key_str, &[&self.key_map.word_forward]) {
self.word_right();
} else if matches(&key_str, &[&self.key_map.character_backward]) {
self.character_left(false);
} else if matches(&key_str, &[&self.key_map.line_previous]) {
self.cursor_up();
} else if matches(&key_str, &[&self.key_map.word_backward]) {
self.word_left();
} else if matches(&key_str, &[&self.key_map.input_begin]) {
self.move_to_begin();
} else if matches(&key_str, &[&self.key_map.input_end]) {
self.move_to_end();
} else if matches(&key_str, &[&self.key_map.lowercase_word_forward]) {
self.lowercase_right();
} else if matches(&key_str, &[&self.key_map.uppercase_word_forward]) {
self.uppercase_right();
} else if matches(&key_str, &[&self.key_map.capitalize_word_forward]) {
self.capitalize_right();
} else if matches(&key_str, &[&self.key_map.transpose_character_backward]) {
self.transpose_left();
} else if !matches(&key_str, &[&self.key_map.paste]) {
let runes: Vec<char> = key.runes.clone();
if !runes.is_empty() {
self.insert_runes_from_user_input(&runes);
}
}
}
self.viewport.update(&msg);
let mut cmds: Vec<Option<Cmd>> = Vec::new();
if let Some(cmd) = self.cursor.update(msg) {
cmds.push(Some(cmd));
}
if (self.row != old_row || self.col != old_col) && self.cursor.mode() == CursorMode::Blink {
cmds.push(Some(blink_cmd()));
}
self.reposition_view();
bubbletea::batch(cmds)
}
#[must_use]
pub fn view(&self) -> String {
if self.value() == "" && self.row == 0 && self.col == 0 && !self.placeholder.is_empty() {
return self.placeholder_view();
}
let style = self.current_style();
let mut lines = Vec::new();
for (l, line) in self.value.iter().enumerate() {
let is_cursor_line = self.row == l;
let line_style = if is_cursor_line {
&style.cursor_line
} else {
&style.text
};
let mut s = String::new();
s.push_str(&style.prompt.render(&self.prompt));
if self.show_line_numbers {
let ln_style = if is_cursor_line {
&style.cursor_line_number
} else {
&style.line_number
};
s.push_str(&ln_style.render(&format!("{:>3} ", l + 1)));
}
let line_str: String = line.iter().collect();
if is_cursor_line && self.focus {
let before: String = line[..self.col.min(line.len())].iter().collect();
s.push_str(&line_style.render(&before));
if self.col < line.len() {
let cursor_char: String = line[self.col..self.col + 1].iter().collect();
let mut cursor = self.cursor.clone();
cursor.set_char(&cursor_char);
s.push_str(&cursor.view());
let after: String = line[self.col + 1..].iter().collect();
s.push_str(&line_style.render(&after));
} else {
let mut cursor = self.cursor.clone();
cursor.set_char(" ");
s.push_str(&cursor.view());
}
} else {
s.push_str(&line_style.render(&line_str));
}
let mut current_line_width: usize = line
.iter()
.map(|c| unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0))
.sum();
if is_cursor_line && self.focus && self.col >= line.len() {
current_line_width += 1; }
let padding = self.width.saturating_sub(current_line_width);
if padding > 0 {
s.push_str(&line_style.render(&" ".repeat(padding)));
}
lines.push(s);
}
while lines.len() < self.height {
let mut s = String::new();
s.push_str(&style.prompt.render(&self.prompt));
if self.show_line_numbers {
s.push_str(&style.line_number.render(" "));
}
s.push_str(
&style
.end_of_buffer
.render(&format!("{}", self.end_of_buffer_character)),
);
let padding = self.width.saturating_sub(1);
s.push_str(&" ".repeat(padding));
lines.push(s);
}
let start = self.viewport.y_offset();
let end = (start + self.height).min(lines.len());
let visible: String = lines[start..end].join("\n");
style.base.render(&visible)
}
fn placeholder_view(&self) -> String {
let style = self.current_style();
let mut lines = Vec::new();
let placeholder_lines: Vec<&str> = self.placeholder.lines().collect();
let reserved = self.prompt_width + if self.show_line_numbers { 4 } else { 0 };
let total_width = reserved + self.width;
for i in 0..self.height {
let mut s = String::new();
s.push_str(&style.prompt.render(&self.prompt));
if self.show_line_numbers {
let ln_style = if i == 0 && self.focus {
&style.cursor_line_number
} else {
&style.line_number
};
if i == 0 {
s.push_str(&ln_style.render(&format!("{:>3} ", 1)));
} else {
s.push_str(&ln_style.render(" "));
}
}
if i < placeholder_lines.len() {
let line = placeholder_lines[i];
if i == 0 && self.focus && !line.is_empty() {
let first: String = line.chars().take(1).collect();
let rest: String = line.chars().skip(1).collect();
let mut cursor = self.cursor.clone();
cursor.text_style = style.placeholder.clone();
cursor.set_char(&first);
s.push_str(&cursor.view());
s.push_str(&style.placeholder.render(&rest));
} else {
s.push_str(&style.placeholder.render(line));
}
} else {
s.push_str(
&style
.end_of_buffer
.render(&format!("{}", self.end_of_buffer_character)),
);
}
let line_width = lipgloss::width(&s);
if line_width < total_width {
s.push_str(&" ".repeat(total_width - line_width));
}
lines.push(s);
}
style.base.render(&lines.join("\n"))
}
}
impl Model for TextArea {
fn init(&self) -> Option<Cmd> {
if self.focus { Some(blink_cmd()) } else { None }
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
TextArea::update(self, msg)
}
fn view(&self) -> String {
TextArea::view(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_textarea_new() {
let ta = TextArea::new();
assert_eq!(ta.height, DEFAULT_HEIGHT);
assert!(ta.show_line_numbers);
assert!(!ta.focused());
}
#[test]
fn test_textarea_set_value() {
let mut ta = TextArea::new();
ta.set_value("Hello\nWorld");
assert_eq!(ta.value(), "Hello\nWorld");
assert_eq!(ta.line_count(), 2);
}
#[test]
fn test_textarea_cursor_navigation() {
let mut ta = TextArea::new();
ta.set_value("Line 1\nLine 2\nLine 3");
assert_eq!(ta.row, 2); ta.move_to_begin();
assert_eq!(ta.row, 0);
assert_eq!(ta.col, 0);
ta.cursor_end();
assert_eq!(ta.col, 6);
ta.cursor_down();
assert_eq!(ta.row, 1);
}
#[test]
fn test_textarea_focus_blur() {
let mut ta = TextArea::new();
assert!(!ta.focused());
ta.focus();
assert!(ta.focused());
ta.blur();
assert!(!ta.focused());
}
#[test]
fn test_textarea_reset() {
let mut ta = TextArea::new();
ta.set_value("Hello\nWorld");
ta.reset();
assert_eq!(ta.value(), "");
assert_eq!(ta.line_count(), 1);
}
#[test]
fn test_textarea_insert_newline() {
let mut ta = TextArea::new();
ta.set_value("Hello");
ta.move_to_begin();
ta.set_cursor_col(2); ta.split_line(0, 2);
assert_eq!(ta.line_count(), 2);
assert_eq!(ta.value(), "He\nllo");
}
#[test]
fn test_textarea_delete_line() {
let mut ta = TextArea::new();
ta.set_value("Line 1\nLine 2\nLine 3");
ta.move_to_begin();
ta.row = 1;
ta.col = 0;
ta.merge_line_above(1);
assert_eq!(ta.line_count(), 2);
assert_eq!(ta.value(), "Line 1Line 2\nLine 3");
}
#[test]
fn test_textarea_char_limit() {
let mut ta = TextArea::new();
ta.char_limit = 10;
ta.set_value("This is a very long string");
assert!(ta.length() <= 10);
}
#[test]
fn test_textarea_dimensions() {
let mut ta = TextArea::new();
ta.set_width(80);
ta.set_height(24);
assert_eq!(ta.height(), 24);
}
#[test]
fn test_textarea_view() {
let mut ta = TextArea::new();
ta.set_value("Hello\nWorld");
let view = ta.view();
assert!(view.contains("Hello"));
assert!(view.contains("World"));
}
#[test]
fn test_textarea_placeholder() {
let mut ta = TextArea::new();
ta.placeholder = "Enter text...".to_string();
let view = ta.view();
assert!(view.contains("E"), "View should contain cursor char 'E'");
assert!(
view.contains("nter text..."),
"View should contain rest of placeholder"
);
}
#[test]
fn test_keymap_default() {
let km = KeyMap::default();
assert!(!km.character_forward.get_keys().is_empty());
assert!(!km.insert_newline.get_keys().is_empty());
}
#[test]
fn test_model_init_unfocused() {
let ta = TextArea::new();
let cmd = Model::init(&ta);
assert!(cmd.is_none());
}
#[test]
fn test_model_init_focused() {
let mut ta = TextArea::new();
ta.focus();
let cmd = Model::init(&ta);
assert!(cmd.is_some());
}
#[test]
fn test_model_view() {
let mut ta = TextArea::new();
ta.set_value("Test content");
let model_view = Model::view(&ta);
let textarea_view = TextArea::view(&ta);
assert_eq!(model_view, textarea_view);
}
#[test]
fn test_model_update_handles_paste_msg() {
use bubbletea::Message;
let mut ta = TextArea::new();
ta.focus();
assert_eq!(ta.value(), "");
let paste_msg = Message::new(PasteMsg("hello world".to_string()));
let _ = Model::update(&mut ta, paste_msg);
assert_eq!(
ta.value(),
"hello world",
"TextArea should insert pasted text"
);
}
#[test]
fn test_model_update_unfocused_ignores_input() {
use bubbletea::{KeyMsg, Message};
let mut ta = TextArea::new();
assert!(!ta.focused());
assert_eq!(ta.value(), "");
let key_msg = Message::new(KeyMsg::from_char('a'));
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.value(), "", "Unfocused textarea should ignore key input");
}
#[test]
fn test_model_update_handles_key_input() {
use bubbletea::{KeyMsg, Message};
let mut ta = TextArea::new();
ta.focus();
assert_eq!(ta.value(), "");
let key_msg = Message::new(KeyMsg::from_char('H'));
let _ = Model::update(&mut ta, key_msg);
assert_eq!(
ta.value(),
"H",
"Focused textarea should insert typed character"
);
}
#[test]
fn test_model_update_handles_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello\nWorld");
ta.move_to_begin();
assert_eq!(ta.row, 0);
assert_eq!(ta.col, 0);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut ta, down_msg);
assert_eq!(ta.row, 1, "TextArea should navigate down on Down key");
}
#[test]
fn test_textarea_satisfies_model_bounds() {
fn requires_model<T: Model + Send + 'static>() {}
requires_model::<TextArea>();
}
#[test]
fn test_model_update_backspace_deletes_char() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello");
ta.move_to_begin();
ta.col = 5;
let backspace_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
let _ = Model::update(&mut ta, backspace_msg);
assert_eq!(
ta.value(),
"Hell",
"Backspace should delete character before cursor"
);
}
#[test]
fn test_model_update_backspace_at_start_noop() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello");
ta.move_to_begin();
assert_eq!(ta.row, 0);
assert_eq!(ta.col, 0);
let backspace_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
let _ = Model::update(&mut ta, backspace_msg);
assert_eq!(ta.value(), "Hello", "Backspace at start should do nothing");
assert_eq!(ta.col, 0);
}
#[test]
fn test_model_update_delete_forward() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello");
ta.move_to_begin();
ta.col = 0;
let delete_msg = Message::new(KeyMsg::from_type(KeyType::Delete));
let _ = Model::update(&mut ta, delete_msg);
assert_eq!(
ta.value(),
"ello",
"Delete should remove character at cursor"
);
}
#[test]
fn test_model_update_cursor_left() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello");
ta.move_to_begin();
ta.col = 3;
let left_msg = Message::new(KeyMsg::from_type(KeyType::Left));
let _ = Model::update(&mut ta, left_msg);
assert_eq!(ta.col, 2, "Left arrow should move cursor left");
}
#[test]
fn test_model_update_cursor_right() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello");
ta.move_to_begin();
ta.col = 0;
let right_msg = Message::new(KeyMsg::from_type(KeyType::Right));
let _ = Model::update(&mut ta, right_msg);
assert_eq!(ta.col, 1, "Right arrow should move cursor right");
}
#[test]
fn test_model_update_cursor_up() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Line1\nLine2\nLine3");
ta.row = 2;
ta.col = 0;
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut ta, up_msg);
assert_eq!(ta.row, 1, "Up arrow should move cursor up");
}
#[test]
fn test_model_update_enter_splits_line() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello World");
ta.move_to_begin();
ta.col = 5;
let enter_msg = Message::new(KeyMsg::from_type(KeyType::Enter));
let _ = Model::update(&mut ta, enter_msg);
assert_eq!(ta.line_count(), 2, "Enter should split into two lines");
assert!(ta.value().contains('\n'), "Value should contain newline");
}
#[test]
fn test_textarea_view_shows_line_numbers() {
let mut ta = TextArea::new();
ta.show_line_numbers = true;
ta.set_value("Line 1\nLine 2\nLine 3");
let view = ta.view();
assert!(
view.contains('1') && view.contains('2') && view.contains('3'),
"View should contain line numbers"
);
}
#[test]
fn test_textarea_view_hides_line_numbers() {
let mut ta = TextArea::new();
ta.show_line_numbers = false;
ta.set_value("A\nB\nC");
let view = ta.view();
assert!(
view.contains('A') && view.contains('B') && view.contains('C'),
"View should contain content"
);
}
#[test]
fn test_textarea_empty_operations() {
let mut ta = TextArea::new();
ta.focus();
assert_eq!(ta.value(), "");
ta.cursor_up();
ta.cursor_down();
ta.cursor_start();
ta.cursor_end();
ta.move_to_begin();
ta.move_to_end();
assert_eq!(
ta.value(),
"",
"Empty textarea should remain empty after navigation"
);
assert_eq!(ta.row, 0);
assert_eq!(ta.col, 0);
}
#[test]
fn test_textarea_unicode_characters() {
let mut ta = TextArea::new();
ta.focus();
ta.set_value("Hello δΈη π¦");
assert_eq!(ta.value(), "Hello δΈη π¦");
let view = ta.view();
assert!(view.contains("δΈη"), "View should render CJK characters");
assert!(view.contains("π¦"), "View should render emoji");
}
#[test]
fn test_textarea_unicode_cursor_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("ζ₯ζ¬θͺ");
ta.move_to_begin();
let right_msg = Message::new(KeyMsg::from_type(KeyType::Right));
let _ = Model::update(&mut ta, right_msg);
assert!(ta.col > 0, "Cursor should advance through unicode");
}
#[test]
fn test_textarea_very_long_line() {
let mut ta = TextArea::new();
ta.set_width(20);
let long_line = "A".repeat(100);
ta.set_value(&long_line);
let view = ta.view();
assert!(!view.is_empty(), "View should render long line");
}
#[test]
fn test_textarea_max_height_enforced() {
let mut ta = TextArea::new();
ta.max_height = 3;
ta.set_value("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
assert!(ta.line_count() >= 3, "Content should be stored");
}
#[test]
fn test_textarea_width_set_propagates() {
let mut ta = TextArea::new();
ta.set_width(80);
let view = ta.view();
assert!(!view.is_empty(), "View should work after width set");
}
#[test]
fn test_textarea_width_uses_prompt_display_width() {
let mut ta = TextArea::new();
ta.show_line_numbers = false;
ta.prompt = "η ".to_string(); ta.set_width(6);
assert_eq!(ta.width(), 3);
}
#[test]
fn test_model_init_returns_blink_when_focused() {
let mut ta = TextArea::new();
ta.focus();
let cmd = Model::init(&ta);
assert!(
cmd.is_some(),
"Focused textarea init should return blink command"
);
}
#[test]
fn test_model_init_returns_none_when_unfocused() {
let ta = TextArea::new();
let cmd = Model::init(&ta);
assert!(cmd.is_none(), "Unfocused textarea init should return None");
}
#[test]
fn test_bracketed_paste_basic() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: vec!['h', 'e', 'l', 'l', 'o'],
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.value(), "hello");
}
#[test]
fn test_bracketed_paste_multiline_preserves_newlines() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "line1\nline2\nline3".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(
ta.value(),
"line1\nline2\nline3",
"TextArea should preserve newlines in paste"
);
assert_eq!(ta.line_count(), 3, "Should have 3 lines after paste");
}
#[test]
fn test_bracketed_paste_crlf_normalized() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "line1\r\nline2".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(
ta.value(),
"line1\nline2",
"CRLF should be normalized to LF"
);
assert_eq!(ta.line_count(), 2);
}
#[test]
fn test_bracketed_paste_respects_char_limit() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.char_limit = 10;
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "this is a very long paste".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.length(), 10, "Paste should respect char_limit");
}
#[test]
fn test_bracketed_paste_unfocused_ignored() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "ignored".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.value(), "", "Unfocused textarea should ignore paste");
}
#[test]
fn test_bracketed_paste_inserts_at_cursor() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
ta.set_value("helloworld");
ta.move_to_begin();
ta.col = 5;
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: " ".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.value(), "hello world");
}
#[test]
fn test_bracketed_paste_unicode() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "hello δΈη π".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.value(), "hello δΈη π");
}
#[test]
fn test_bracketed_paste_large_content() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let large_text: String = "a".repeat(1000);
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: large_text.chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(
ta.value().len(),
1000,
"Large paste should work without issues"
);
}
#[test]
fn test_bracketed_paste_multiline_cursor_position() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut ta = TextArea::new();
ta.focus();
let key_msg = Message::new(KeyMsg {
key_type: KeyType::Runes,
runes: "line1\nline2\nline3".chars().collect(),
alt: false,
paste: true,
});
let _ = Model::update(&mut ta, key_msg);
assert_eq!(ta.row, 2, "Cursor should be on line 3 (index 2)");
assert_eq!(ta.col, 5, "Cursor should be at end of 'line3'");
}
}