use std::cmp;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Position {
pub row: usize,
pub col: usize,
}
impl Position {
pub fn new(row: usize, col: usize) -> Self {
Self { row, col }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Selection {
pub start: Position,
pub end: Position,
}
impl Selection {
pub fn new(start: Position, end: Position) -> Self {
if start.row < end.row || (start.row == end.row && start.col <= end.col) {
Self { start, end }
} else {
Self {
start: end,
end: start,
}
}
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
}
#[derive(Debug, Clone)]
pub struct TextAreaState {
lines: Vec<String>,
cursor: Position,
selection: Option<Selection>,
max_lines: Option<usize>,
max_length: Option<usize>,
read_only: bool,
scroll_offset: usize,
viewport_height: usize,
char_limit: Option<usize>,
placeholder: String,
show_line_numbers: bool,
tab_width: usize,
soft_tabs: bool,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
lines: vec![String::new()],
cursor: Position::default(),
selection: None,
max_lines: None,
max_length: None,
read_only: false,
scroll_offset: 0,
viewport_height: 10,
char_limit: None,
placeholder: String::new(),
show_line_numbers: false,
tab_width: 4,
soft_tabs: true,
}
}
}
impl TextAreaState {
pub fn new() -> Self {
Self::default()
}
pub fn with_content(content: &str) -> Self {
let mut state = Self::new();
state.set_content(content);
state
}
pub fn with_size(viewport_height: usize) -> Self {
Self {
viewport_height,
..Default::default()
}
}
pub fn set_content(&mut self, content: &str) {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
self.lines = normalized.lines().map(String::from).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.clamp_cursor();
self.selection = None;
}
pub fn content(&self) -> String {
self.lines.join("\n")
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn line(&self, row: usize) -> Option<&str> {
self.lines.get(row).map(|s| s.as_str())
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn char_count(&self) -> usize {
self.lines.iter().map(|l| l.chars().count()).sum::<usize>()
+ self.lines.len().saturating_sub(1) }
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn clear(&mut self) {
self.lines = vec![String::new()];
self.cursor = Position::default();
self.selection = None;
self.scroll_offset = 0;
}
pub fn cursor(&self) -> Position {
self.cursor
}
pub fn set_cursor(&mut self, pos: Position) {
self.cursor = pos;
self.clamp_cursor();
self.ensure_cursor_visible();
}
pub fn cursor_row(&self) -> usize {
self.cursor.row
}
pub fn cursor_col(&self) -> usize {
self.cursor.col
}
pub fn move_left(&mut self) {
if self.cursor.col > 0 {
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.current_line_len();
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_right(&mut self) {
let line_len = self.current_line_len();
if self.cursor.col < line_len {
self.cursor.col += 1;
} else if self.cursor.row < self.lines.len() - 1 {
self.cursor.row += 1;
self.cursor.col = 0;
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_up(&mut self) {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.clamp_cursor_col();
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_down(&mut self) {
if self.cursor.row < self.lines.len() - 1 {
self.cursor.row += 1;
self.clamp_cursor_col();
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_to_line_start(&mut self) {
self.cursor.col = 0;
self.clear_selection();
}
pub fn move_to_line_end(&mut self) {
self.cursor.col = self.current_line_len();
self.clear_selection();
}
pub fn move_to_start(&mut self) {
self.cursor = Position::default();
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_to_end(&mut self) {
self.cursor.row = self.lines.len() - 1;
self.cursor.col = self.current_line_len();
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_word_left(&mut self) {
if self.cursor.col == 0 {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.current_line_len();
}
} else {
let line = &self.lines[self.cursor.row];
let chars: Vec<char> = line.chars().collect();
let mut col = self.cursor.col;
while col > 0 && chars.get(col - 1).is_some_and(|c| c.is_whitespace()) {
col -= 1;
}
while col > 0 && chars.get(col - 1).is_some_and(|c| !c.is_whitespace()) {
col -= 1;
}
self.cursor.col = col;
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn move_word_right(&mut self) {
let line_len = self.current_line_len();
if self.cursor.col >= line_len {
if self.cursor.row < self.lines.len() - 1 {
self.cursor.row += 1;
self.cursor.col = 0;
}
} else {
let line = &self.lines[self.cursor.row];
let chars: Vec<char> = line.chars().collect();
let mut col = self.cursor.col;
while col < chars.len() && !chars[col].is_whitespace() {
col += 1;
}
while col < chars.len() && chars[col].is_whitespace() {
col += 1;
}
self.cursor.col = col;
}
self.clear_selection();
self.ensure_cursor_visible();
}
pub fn insert_char(&mut self, ch: char) {
if self.read_only {
return;
}
if let Some(limit) = self.char_limit {
if self.char_count() >= limit {
return;
}
}
self.delete_selection();
if ch == '\n' {
self.insert_newline();
} else if ch == '\t' {
self.insert_tab();
} else {
if let Some(max_len) = self.max_length {
if self.current_line_len() >= max_len {
return;
}
}
let line = &mut self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col);
line.insert(byte_pos, ch);
self.cursor.col += 1;
}
self.ensure_cursor_visible();
}
pub fn insert_string(&mut self, s: &str) {
if self.read_only {
return;
}
self.delete_selection();
for ch in s.chars() {
if let Some(limit) = self.char_limit {
if self.char_count() >= limit {
break;
}
}
if ch == '\n' {
if let Some(max_lines) = self.max_lines {
if self.lines.len() >= max_lines {
continue;
}
}
self.insert_newline();
} else if ch == '\t' {
self.insert_tab();
} else {
if let Some(max_len) = self.max_length {
if self.current_line_len() >= max_len {
continue;
}
}
let line = &mut self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col);
line.insert(byte_pos, ch);
self.cursor.col += 1;
}
}
self.ensure_cursor_visible();
}
fn insert_newline(&mut self) {
if let Some(max_lines) = self.max_lines {
if self.lines.len() >= max_lines {
return;
}
}
let line = &self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col);
let rest = line[byte_pos..].to_string();
self.lines[self.cursor.row].truncate(byte_pos);
self.cursor.row += 1;
self.cursor.col = 0;
self.lines.insert(self.cursor.row, rest);
}
fn insert_tab(&mut self) {
if self.soft_tabs {
let spaces_needed = self.tab_width - (self.cursor.col % self.tab_width);
for _ in 0..spaces_needed {
self.insert_char(' ');
}
} else {
let line = &mut self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col);
line.insert(byte_pos, '\t');
self.cursor.col += 1;
}
}
pub fn delete_before_cursor(&mut self) {
if self.read_only {
return;
}
if self.delete_selection() {
return;
}
if self.cursor.col > 0 {
let line = &mut self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col - 1);
let end_pos = char_to_byte_pos(line, self.cursor.col);
line.replace_range(byte_pos..end_pos, "");
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
let current_line = self.lines.remove(self.cursor.row);
self.cursor.row -= 1;
self.cursor.col = self.lines[self.cursor.row].chars().count();
self.lines[self.cursor.row].push_str(¤t_line);
}
self.ensure_cursor_visible();
}
pub fn delete_after_cursor(&mut self) {
if self.read_only {
return;
}
if self.delete_selection() {
return;
}
let line_len = self.current_line_len();
if self.cursor.col < line_len {
let line = &mut self.lines[self.cursor.row];
let byte_pos = char_to_byte_pos(line, self.cursor.col);
let end_pos = char_to_byte_pos(line, self.cursor.col + 1);
line.replace_range(byte_pos..end_pos, "");
} else if self.cursor.row < self.lines.len() - 1 {
let next_line = self.lines.remove(self.cursor.row + 1);
self.lines[self.cursor.row].push_str(&next_line);
}
}
pub fn delete_word_before(&mut self) {
if self.read_only {
return;
}
if self.delete_selection() {
return;
}
let start_col = self.cursor.col;
self.move_word_left();
let end_col = self.cursor.col;
if start_col > end_col {
let line = &mut self.lines[self.cursor.row];
let start_byte = char_to_byte_pos(line, end_col);
let end_byte = char_to_byte_pos(line, start_col);
line.replace_range(start_byte..end_byte, "");
}
}
pub fn delete_word_after(&mut self) {
if self.read_only {
return;
}
if self.delete_selection() {
return;
}
let start_col = self.cursor.col;
let line = &self.lines[self.cursor.row];
let chars: Vec<char> = line.chars().collect();
let mut end_col = start_col;
while end_col < chars.len() && !chars[end_col].is_whitespace() {
end_col += 1;
}
while end_col < chars.len() && chars[end_col].is_whitespace() {
end_col += 1;
}
if end_col > start_col {
let line = &mut self.lines[self.cursor.row];
let start_byte = char_to_byte_pos(line, start_col);
let end_byte = char_to_byte_pos(line, end_col);
line.replace_range(start_byte..end_byte, "");
}
}
pub fn delete_line(&mut self) {
if self.read_only {
return;
}
if self.lines.len() > 1 {
self.lines.remove(self.cursor.row);
if self.cursor.row >= self.lines.len() {
self.cursor.row = self.lines.len() - 1;
}
self.clamp_cursor_col();
} else {
self.lines[0].clear();
self.cursor.col = 0;
}
self.selection = None;
self.ensure_cursor_visible();
}
pub fn selection(&self) -> Option<Selection> {
self.selection
}
pub fn has_selection(&self) -> bool {
self.selection.is_some_and(|s| !s.is_empty())
}
pub fn select_to(&mut self, pos: Position) {
let start = self.selection.map_or(self.cursor, |s| s.start);
self.selection = Some(Selection::new(start, pos));
self.cursor = pos;
self.clamp_cursor();
}
pub fn select_all(&mut self) {
let end = Position::new(
self.lines.len() - 1,
self.lines.last().map_or(0, |l| l.chars().count()),
);
self.selection = Some(Selection::new(Position::default(), end));
self.cursor = end;
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
pub fn selected_text(&self) -> Option<String> {
let sel = self.selection?;
if sel.is_empty() {
return None;
}
let mut result = String::new();
for row in sel.start.row..=sel.end.row {
if row >= self.lines.len() {
break;
}
let line = &self.lines[row];
let start_col = if row == sel.start.row {
sel.start.col
} else {
0
};
let end_col = if row == sel.end.row {
sel.end.col
} else {
line.chars().count()
};
let start_byte = char_to_byte_pos(line, start_col);
let end_byte = char_to_byte_pos(line, end_col);
if start_byte < line.len() {
result.push_str(&line[start_byte..end_byte.min(line.len())]);
}
if row < sel.end.row {
result.push('\n');
}
}
Some(result)
}
fn delete_selection(&mut self) -> bool {
let sel = match self.selection {
Some(s) if !s.is_empty() => s,
_ => return false,
};
let end_line = &self.lines[sel.end.row];
let end_byte = char_to_byte_pos(end_line, sel.end.col);
let after_selection = end_line[end_byte..].to_string();
let start_byte = char_to_byte_pos(&self.lines[sel.start.row], sel.start.col);
self.lines[sel.start.row].truncate(start_byte);
self.lines[sel.start.row].push_str(&after_selection);
if sel.end.row > sel.start.row {
self.lines.drain(sel.start.row + 1..=sel.end.row);
}
self.cursor = sel.start;
self.selection = None;
true
}
pub fn set_max_lines(&mut self, max: Option<usize>) {
self.max_lines = max;
}
pub fn set_max_length(&mut self, max: Option<usize>) {
self.max_length = max;
}
pub fn set_char_limit(&mut self, limit: Option<usize>) {
self.char_limit = limit;
}
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
pub fn is_read_only(&self) -> bool {
self.read_only
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_viewport_height(&mut self, height: usize) {
self.viewport_height = height;
self.ensure_cursor_visible();
}
pub fn viewport_height(&self) -> usize {
self.viewport_height
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_tab_width(&mut self, width: usize) {
self.tab_width = width.max(1);
}
pub fn set_soft_tabs(&mut self, soft: bool) {
self.soft_tabs = soft;
}
pub fn set_show_line_numbers(&mut self, show: bool) {
self.show_line_numbers = show;
}
pub fn show_line_numbers(&self) -> bool {
self.show_line_numbers
}
pub fn visible_lines(&self) -> impl Iterator<Item = (usize, &str)> {
self.lines
.iter()
.enumerate()
.skip(self.scroll_offset)
.take(self.viewport_height)
.map(|(i, s)| (i, s.as_str()))
}
fn ensure_cursor_visible(&mut self) {
if self.cursor.row < self.scroll_offset {
self.scroll_offset = self.cursor.row;
} else if self.cursor.row >= self.scroll_offset + self.viewport_height {
self.scroll_offset = self.cursor.row - self.viewport_height + 1;
}
}
fn current_line_len(&self) -> usize {
self.lines
.get(self.cursor.row)
.map_or(0, |l| l.chars().count())
}
fn clamp_cursor(&mut self) {
self.cursor.row = cmp::min(self.cursor.row, self.lines.len().saturating_sub(1));
self.clamp_cursor_col();
}
fn clamp_cursor_col(&mut self) {
self.cursor.col = cmp::min(self.cursor.col, self.current_line_len());
}
}
fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
s.char_indices().nth(char_pos).map_or(s.len(), |(i, _)| i)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_state() {
let state = TextAreaState::new();
assert_eq!(state.line_count(), 1);
assert!(state.is_empty());
assert_eq!(state.cursor(), Position::default());
}
#[test]
fn test_set_content() {
let mut state = TextAreaState::new();
state.set_content("line1\nline2\nline3");
assert_eq!(state.line_count(), 3);
assert_eq!(state.line(0), Some("line1"));
assert_eq!(state.line(1), Some("line2"));
assert_eq!(state.line(2), Some("line3"));
}
#[test]
fn test_insert_char() {
let mut state = TextAreaState::new();
state.insert_char('H');
state.insert_char('i');
assert_eq!(state.content(), "Hi");
assert_eq!(state.cursor(), Position::new(0, 2));
}
#[test]
fn test_insert_newline() {
let mut state = TextAreaState::new();
state.insert_string("Hello");
state.insert_char('\n');
state.insert_string("World");
assert_eq!(state.line_count(), 2);
assert_eq!(state.content(), "Hello\nWorld");
}
#[test]
fn test_delete_before_cursor() {
let mut state = TextAreaState::new();
state.insert_string("Hello");
state.delete_before_cursor();
assert_eq!(state.content(), "Hell");
}
#[test]
fn test_delete_merge_lines() {
let mut state = TextAreaState::new();
state.set_content("Hello\nWorld");
state.set_cursor(Position::new(1, 0));
state.delete_before_cursor();
assert_eq!(state.content(), "HelloWorld");
assert_eq!(state.line_count(), 1);
}
#[test]
fn test_cursor_movement() {
let mut state = TextAreaState::new();
state.set_content("Hello\nWorld");
state.move_right();
assert_eq!(state.cursor(), Position::new(0, 1));
state.move_to_line_end();
assert_eq!(state.cursor(), Position::new(0, 5));
state.move_down();
assert_eq!(state.cursor(), Position::new(1, 5));
state.move_to_start();
assert_eq!(state.cursor(), Position::default());
}
#[test]
fn test_selection() {
let mut state = TextAreaState::new();
state.set_content("Hello World");
state.select_to(Position::new(0, 5));
assert!(state.has_selection());
assert_eq!(state.selected_text(), Some("Hello".to_string()));
}
#[test]
fn test_select_all() {
let mut state = TextAreaState::new();
state.set_content("Hello\nWorld");
state.select_all();
assert_eq!(state.selected_text(), Some("Hello\nWorld".to_string()));
}
#[test]
fn test_delete_selection() {
let mut state = TextAreaState::new();
state.set_content("Hello World");
state.select_to(Position::new(0, 6));
state.insert_char('X');
assert_eq!(state.content(), "XWorld");
}
#[test]
fn test_max_lines() {
let mut state = TextAreaState::new();
state.set_max_lines(Some(2));
state.insert_string("Line1\nLine2\nLine3");
assert_eq!(state.line_count(), 2);
}
#[test]
fn test_word_navigation() {
let mut state = TextAreaState::new();
state.set_content("Hello World Test");
state.move_word_right();
assert_eq!(state.cursor().col, 6);
state.move_word_right();
assert_eq!(state.cursor().col, 12);
state.move_word_left();
assert_eq!(state.cursor().col, 6);
}
}