use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::traits::{ClickRegion, FocusId};
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
fn char_at(s: &str, index: usize) -> Option<char> {
s.chars().nth(index)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextAreaAction {
Focus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabConfig {
Spaces(usize),
Literal,
}
impl Default for TabConfig {
fn default() -> Self {
TabConfig::Spaces(4)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WrapMode {
#[default]
None,
Soft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CursorMode {
#[default]
Block,
Terminal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollMode {
#[default]
Minimal,
CenterTracking,
}
pub struct TextAreaRender {
pub click_region: ClickRegion<TextAreaAction>,
pub cursor_position: Option<(u16, u16)>,
}
#[derive(Debug, Clone)]
pub struct TextAreaState {
pub lines: Vec<String>,
pub cursor_line: usize,
pub cursor_col: usize,
pub scroll_y: usize,
pub scroll_x: usize,
pub visible_height: usize,
pub focused: bool,
pub enabled: bool,
pub tab_config: TabConfig,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
lines: vec![String::new()],
cursor_line: 0,
cursor_col: 0,
scroll_y: 0,
scroll_x: 0,
visible_height: 0,
focused: false,
enabled: true,
tab_config: TabConfig::default(),
}
}
}
impl TextAreaState {
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
let lines: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.lines().map(|s| s.to_string()).collect()
};
let lines = if lines.is_empty() {
vec![String::new()]
} else {
lines
};
Self {
lines,
cursor_line: 0,
cursor_col: 0,
scroll_y: 0,
scroll_x: 0,
visible_height: 0,
focused: false,
enabled: true,
tab_config: TabConfig::default(),
}
}
pub fn empty() -> Self {
Self::default()
}
pub fn with_tab_config(mut self, config: TabConfig) -> Self {
self.tab_config = config;
self
}
pub fn insert_char(&mut self, c: char) {
if !self.enabled {
return;
}
let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
self.lines[self.cursor_line].insert(byte_pos, c);
self.cursor_col += 1;
}
pub fn insert_str(&mut self, s: &str) {
if !self.enabled {
return;
}
for c in s.chars() {
if c == '\n' {
self.insert_newline();
} else if c != '\r' {
self.insert_char(c);
}
}
}
pub fn insert_newline(&mut self) {
if !self.enabled {
return;
}
let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
let rest = self.lines[self.cursor_line][byte_pos..].to_string();
self.lines[self.cursor_line].truncate(byte_pos);
self.cursor_line += 1;
self.lines.insert(self.cursor_line, rest);
self.cursor_col = 0;
self.ensure_cursor_visible();
}
pub fn insert_tab(&mut self) {
if !self.enabled {
return;
}
match self.tab_config {
TabConfig::Spaces(n) => {
for _ in 0..n {
self.insert_char(' ');
}
}
TabConfig::Literal => {
self.insert_char('\t');
}
}
}
pub fn delete_char_backward(&mut self) -> bool {
if !self.enabled {
return false;
}
if self.cursor_col > 0 {
self.cursor_col -= 1;
let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
self.lines[self.cursor_line].replace_range(byte_pos..byte_pos + c.len_utf8(), "");
return true;
}
} else if self.cursor_line > 0 {
let current_line = self.lines.remove(self.cursor_line);
self.cursor_line -= 1;
self.cursor_col = self.lines[self.cursor_line].chars().count();
self.lines[self.cursor_line].push_str(¤t_line);
self.ensure_cursor_visible();
return true;
}
false
}
pub fn delete_char_forward(&mut self) -> bool {
if !self.enabled {
return false;
}
let line_len = self.lines[self.cursor_line].chars().count();
if self.cursor_col < line_len {
let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
self.lines[self.cursor_line].replace_range(byte_pos..byte_pos + c.len_utf8(), "");
return true;
}
} else if self.cursor_line + 1 < self.lines.len() {
let next_line = self.lines.remove(self.cursor_line + 1);
self.lines[self.cursor_line].push_str(&next_line);
return true;
}
false
}
pub fn delete_word_backward(&mut self) -> bool {
if !self.enabled || (self.cursor_col == 0 && self.cursor_line == 0) {
return false;
}
if self.cursor_col == 0 {
return self.delete_char_backward();
}
let start_col = self.cursor_col;
let line = &self.lines[self.cursor_line];
while self.cursor_col > 0 {
if let Some(c) = char_at(line, self.cursor_col - 1) {
if c.is_whitespace() {
self.cursor_col -= 1;
} else {
break;
}
} else {
break;
}
}
while self.cursor_col > 0 {
if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col - 1) {
if !c.is_whitespace() {
self.delete_char_backward();
} else {
break;
}
} else {
break;
}
}
start_col != self.cursor_col
}
pub fn delete_word_forward(&mut self) -> bool {
if !self.enabled {
return false;
}
let line_len = self.lines[self.cursor_line].chars().count();
if self.cursor_col >= line_len {
if self.cursor_line + 1 < self.lines.len() {
return self.delete_char_forward();
}
return false;
}
let start_col = self.cursor_col;
while self.cursor_col < self.lines[self.cursor_line].chars().count() {
if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
if !c.is_whitespace() {
self.delete_char_forward();
} else {
break;
}
} else {
break;
}
}
while self.cursor_col < self.lines[self.cursor_line].chars().count() {
if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
if c.is_whitespace() {
self.delete_char_forward();
} else {
break;
}
} else {
break;
}
}
start_col != self.cursor_col || self.lines[self.cursor_line].chars().count() < line_len
}
pub fn delete_line(&mut self) {
if !self.enabled {
return;
}
if self.lines.len() == 1 {
self.lines[0].clear();
self.cursor_col = 0;
} else {
self.lines.remove(self.cursor_line);
if self.cursor_line >= self.lines.len() {
self.cursor_line = self.lines.len().saturating_sub(1);
}
let new_line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(new_line_len);
}
self.ensure_cursor_visible();
}
pub fn delete_to_line_start(&mut self) {
if !self.enabled || self.cursor_col == 0 {
return;
}
let line = &self.lines[self.cursor_line];
let byte_pos = char_to_byte_index(line, self.cursor_col);
self.lines[self.cursor_line] = line[byte_pos..].to_string();
self.cursor_col = 0;
}
pub fn delete_to_line_end(&mut self) {
if !self.enabled {
return;
}
let line = &self.lines[self.cursor_line];
let byte_pos = char_to_byte_index(line, self.cursor_col);
self.lines[self.cursor_line] = line[..byte_pos].to_string();
}
pub fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_line > 0 {
self.cursor_line -= 1;
self.cursor_col = self.lines[self.cursor_line].chars().count();
self.ensure_cursor_visible();
}
}
pub fn move_right(&mut self) {
let line_len = self.lines[self.cursor_line].chars().count();
if self.cursor_col < line_len {
self.cursor_col += 1;
} else if self.cursor_line + 1 < self.lines.len() {
self.cursor_line += 1;
self.cursor_col = 0;
self.ensure_cursor_visible();
}
}
pub fn move_line_start(&mut self) {
self.cursor_col = 0;
}
pub fn move_line_end(&mut self) {
self.cursor_col = self.lines[self.cursor_line].chars().count();
}
pub fn move_word_left(&mut self) {
if self.cursor_col == 0 {
if self.cursor_line > 0 {
self.cursor_line -= 1;
self.cursor_col = self.lines[self.cursor_line].chars().count();
self.ensure_cursor_visible();
}
return;
}
let line = &self.lines[self.cursor_line];
while self.cursor_col > 0 {
if let Some(c) = char_at(line, self.cursor_col - 1) {
if c.is_whitespace() {
self.cursor_col -= 1;
} else {
break;
}
} else {
break;
}
}
while self.cursor_col > 0 {
if let Some(c) = char_at(line, self.cursor_col - 1) {
if !c.is_whitespace() {
self.cursor_col -= 1;
} else {
break;
}
} else {
break;
}
}
}
pub fn move_word_right(&mut self) {
let line = &self.lines[self.cursor_line];
let line_len = line.chars().count();
if self.cursor_col >= line_len {
if self.cursor_line + 1 < self.lines.len() {
self.cursor_line += 1;
self.cursor_col = 0;
self.ensure_cursor_visible();
}
return;
}
while self.cursor_col < line_len {
if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
if !c.is_whitespace() {
self.cursor_col += 1;
} else {
break;
}
} else {
break;
}
}
let line_len = self.lines[self.cursor_line].chars().count();
while self.cursor_col < line_len {
if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
if c.is_whitespace() {
self.cursor_col += 1;
} else {
break;
}
} else {
break;
}
}
}
pub fn move_up(&mut self) {
if self.cursor_line > 0 {
self.cursor_line -= 1;
let new_line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(new_line_len);
self.ensure_cursor_visible();
}
}
pub fn move_down(&mut self) {
if self.cursor_line + 1 < self.lines.len() {
self.cursor_line += 1;
let new_line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(new_line_len);
self.ensure_cursor_visible();
}
}
pub fn move_page_up(&mut self) {
let page_size = self.visible_height.max(1);
if self.cursor_line >= page_size {
self.cursor_line -= page_size;
} else {
self.cursor_line = 0;
}
let new_line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(new_line_len);
self.ensure_cursor_visible();
}
pub fn move_page_down(&mut self) {
let page_size = self.visible_height.max(1);
let max_line = self.lines.len().saturating_sub(1);
self.cursor_line = (self.cursor_line + page_size).min(max_line);
let new_line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(new_line_len);
self.ensure_cursor_visible();
}
pub fn move_to_start(&mut self) {
self.cursor_line = 0;
self.cursor_col = 0;
self.ensure_cursor_visible();
}
pub fn move_to_end(&mut self) {
self.cursor_line = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines[self.cursor_line].chars().count();
self.ensure_cursor_visible();
}
pub fn scroll_to_cursor(&mut self) {
if self.cursor_line < self.scroll_y {
self.scroll_y = self.cursor_line;
} else if self.visible_height > 0 && self.cursor_line >= self.scroll_y + self.visible_height
{
self.scroll_y = self.cursor_line - self.visible_height + 1;
}
}
pub fn ensure_cursor_visible(&mut self) {
self.scroll_to_cursor();
}
pub fn scroll_up(&mut self) {
self.scroll_y = self.scroll_y.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
let max_scroll = self.lines.len().saturating_sub(self.visible_height.max(1));
if self.scroll_y < max_scroll {
self.scroll_y += 1;
}
}
pub fn scroll_left(&mut self) {
self.scroll_x = self.scroll_x.saturating_sub(4);
}
pub fn scroll_right(&mut self) {
self.scroll_x += 4;
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
self.lines = if text.is_empty() {
vec![String::new()]
} else {
text.lines().map(|s| s.to_string()).collect()
};
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.cursor_line = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines[self.cursor_line].chars().count();
self.scroll_y = 0;
self.scroll_x = 0;
}
pub fn clear(&mut self) {
self.lines = vec![String::new()];
self.cursor_line = 0;
self.cursor_col = 0;
self.scroll_y = 0;
self.scroll_x = 0;
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn visual_line_count(&self, content_width: usize) -> usize {
if content_width == 0 {
return self.lines.len();
}
self.lines
.iter()
.map(|line| {
let char_count = line.chars().count();
if char_count == 0 {
1
} else {
(char_count + content_width - 1) / content_width
}
})
.sum::<usize>()
.max(1)
}
pub fn current_line(&self) -> &str {
&self.lines[self.cursor_line]
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn len(&self) -> usize {
let line_chars: usize = self.lines.iter().map(|l| l.chars().count()).sum();
let newlines = self.lines.len().saturating_sub(1);
line_chars + newlines
}
pub fn text_before_cursor(&self) -> &str {
let line = &self.lines[self.cursor_line];
let byte_pos = char_to_byte_index(line, self.cursor_col);
&line[..byte_pos]
}
pub fn text_after_cursor(&self) -> &str {
let line = &self.lines[self.cursor_line];
let byte_pos = char_to_byte_index(line, self.cursor_col);
&line[byte_pos..]
}
}
#[derive(Debug, Clone)]
pub struct TextAreaStyle {
pub focused_border: Color,
pub unfocused_border: Color,
pub disabled_border: Color,
pub text_fg: Color,
pub cursor_fg: Color,
pub placeholder_fg: Color,
pub line_number_fg: Color,
pub current_line_bg: Option<Color>,
pub show_line_numbers: bool,
pub cursor_mode: CursorMode,
pub scroll_mode: ScrollMode,
}
impl Default for TextAreaStyle {
fn default() -> Self {
Self {
focused_border: Color::Yellow,
unfocused_border: Color::Gray,
disabled_border: Color::DarkGray,
text_fg: Color::White,
cursor_fg: Color::Yellow,
placeholder_fg: Color::DarkGray,
line_number_fg: Color::DarkGray,
current_line_bg: None,
show_line_numbers: false,
cursor_mode: CursorMode::default(),
scroll_mode: ScrollMode::default(),
}
}
}
impl From<&crate::theme::Theme> for TextAreaStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
focused_border: p.border_focused,
unfocused_border: p.border,
disabled_border: p.border_disabled,
text_fg: p.text,
cursor_fg: p.primary,
placeholder_fg: p.text_placeholder,
line_number_fg: p.text_disabled,
current_line_bg: None,
show_line_numbers: false,
cursor_mode: CursorMode::default(),
scroll_mode: ScrollMode::default(),
}
}
}
impl TextAreaStyle {
pub fn focused_border(mut self, color: Color) -> Self {
self.focused_border = color;
self
}
pub fn unfocused_border(mut self, color: Color) -> Self {
self.unfocused_border = color;
self
}
pub fn disabled_border(mut self, color: Color) -> Self {
self.disabled_border = color;
self
}
pub fn text_fg(mut self, color: Color) -> Self {
self.text_fg = color;
self
}
pub fn cursor_fg(mut self, color: Color) -> Self {
self.cursor_fg = color;
self
}
pub fn placeholder_fg(mut self, color: Color) -> Self {
self.placeholder_fg = color;
self
}
pub fn line_number_fg(mut self, color: Color) -> Self {
self.line_number_fg = color;
self
}
pub fn current_line_bg(mut self, color: Option<Color>) -> Self {
self.current_line_bg = color;
self
}
pub fn show_line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
self
}
pub fn cursor_mode(mut self, mode: CursorMode) -> Self {
self.cursor_mode = mode;
self
}
pub fn scroll_mode(mut self, mode: ScrollMode) -> Self {
self.scroll_mode = mode;
self
}
}
pub struct TextArea<'a> {
label: Option<&'a str>,
placeholder: Option<&'a str>,
style: TextAreaStyle,
focus_id: FocusId,
with_border: bool,
wrap_mode: WrapMode,
title: Option<Line<'a>>,
content_lines: Option<Vec<Line<'a>>>,
border_color_override: Option<Color>,
}
impl TextArea<'_> {
pub fn new() -> Self {
Self {
label: None,
placeholder: None,
style: TextAreaStyle::default(),
focus_id: FocusId::default(),
with_border: true,
wrap_mode: WrapMode::default(),
title: None,
content_lines: None,
border_color_override: None,
}
}
}
impl Default for TextArea<'_> {
fn default() -> Self {
Self::new()
}
}
impl<'a> TextArea<'a> {
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = Some(placeholder);
self
}
pub fn style(mut self, style: TextAreaStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(TextAreaStyle::from(theme))
}
pub fn focus_id(mut self, id: FocusId) -> Self {
self.focus_id = id;
self
}
pub fn with_border(mut self, with_border: bool) -> Self {
self.with_border = with_border;
self
}
pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
self.wrap_mode = mode;
self
}
pub fn title(mut self, title: Line<'a>) -> Self {
self.title = Some(title);
self
}
pub fn content_lines(mut self, lines: Vec<Line<'a>>) -> Self {
self.content_lines = Some(lines);
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color_override = Some(color);
self
}
pub fn render_stateful(
self,
frame: &mut Frame,
area: Rect,
state: &mut TextAreaState,
) -> TextAreaRender {
let border_color = if let Some(override_color) = self.border_color_override {
override_color
} else if !state.enabled {
self.style.disabled_border
} else if state.focused {
self.style.focused_border
} else {
self.style.unfocused_border
};
let block = if self.with_border {
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
if let Some(title) = self.title {
block = block.title(title);
} else if let Some(label) = self.label {
block = block.title(format!(" {} ", label));
}
Some(block)
} else {
None
};
let inner_area = if let Some(ref b) = block {
b.inner(area)
} else {
area
};
state.visible_height = inner_area.height as usize;
let line_num_width = if self.style.show_line_numbers {
let max_line = state.lines.len();
let digits = max_line.to_string().len();
digits + 2 } else {
0
};
let content_width = (inner_area.width as usize).saturating_sub(line_num_width);
let use_terminal_cursor = self.style.cursor_mode == CursorMode::Terminal;
if state.is_empty() && !state.focused {
if let Some(placeholder) = self.placeholder {
let display_line = Line::from(Span::styled(
placeholder,
Style::default().fg(self.style.placeholder_fg),
));
let paragraph = Paragraph::new(display_line);
if let Some(block) = block {
frame.render_widget(block, area);
}
frame.render_widget(paragraph, inner_area);
return TextAreaRender {
click_region: ClickRegion::new(area, TextAreaAction::Focus),
cursor_position: None,
};
}
}
let mut display_lines: Vec<Line> = Vec::new();
let mut cursor_screen_pos: Option<(u16, u16)> = None;
if self.wrap_mode == WrapMode::Soft && content_width > 0 {
let mut visual_rows: Vec<(usize, usize)> = Vec::new();
for (li, line) in state.lines.iter().enumerate() {
let char_count = line.chars().count();
if char_count == 0 {
visual_rows.push((li, 0));
} else {
let mut col = 0;
loop {
visual_rows.push((li, col));
col += content_width;
if col >= char_count {
break;
}
}
}
}
let total_visual_rows = visual_rows.len();
let cursor_visual_row = visual_rows
.iter()
.enumerate()
.rev()
.find(|(_, (li, vc))| {
*li == state.cursor_line && state.cursor_col >= *vc
})
.map(|(i, _)| i)
.unwrap_or(0);
let effective_scroll_vr =
if self.style.scroll_mode == ScrollMode::CenterTracking && state.visible_height > 0
{
let half_height = state.visible_height / 2;
if total_visual_rows <= state.visible_height
|| cursor_visual_row <= half_height
{
0
} else if cursor_visual_row + half_height >= total_visual_rows {
total_visual_rows.saturating_sub(state.visible_height)
} else {
cursor_visual_row.saturating_sub(half_height)
}
} else {
visual_rows
.iter()
.position(|(li, _)| *li >= state.scroll_y)
.unwrap_or(0)
};
let start_vr = effective_scroll_vr;
let end_vr = (start_vr + state.visible_height).min(total_visual_rows);
for (vr_offset, vr_idx) in (start_vr..end_vr).enumerate() {
let (line_idx, start_col) = visual_rows[vr_idx];
let is_cursor_line = line_idx == state.cursor_line;
let display_row = vr_offset as u16;
let line = &state.lines[line_idx];
let chars: Vec<char> = line.chars().collect();
let visible_chars: String =
chars.iter().skip(start_col).take(content_width).collect();
let mut spans = Vec::new();
if self.style.show_line_numbers {
if start_col == 0 {
let line_num = format!(
"{:>width$} ",
line_idx + 1,
width = line_num_width.saturating_sub(2)
);
spans.push(Span::styled(
line_num,
Style::default().fg(self.style.line_number_fg),
));
} else {
spans.push(Span::raw(" ".repeat(line_num_width)));
}
}
let line_style = if is_cursor_line {
if let Some(bg) = self.style.current_line_bg {
Style::default().fg(self.style.text_fg).bg(bg)
} else {
Style::default().fg(self.style.text_fg)
}
} else {
Style::default().fg(self.style.text_fg)
};
let is_last_vr_for_line =
vr_idx + 1 >= visual_rows.len() || visual_rows[vr_idx + 1].0 != line_idx;
let cursor_on_this_vr = is_cursor_line
&& state.cursor_col >= start_col
&& (is_last_vr_for_line || state.cursor_col < start_col + content_width);
if cursor_on_this_vr && state.focused {
let cursor_visible_col = state.cursor_col - start_col;
let visible_char_count = visible_chars.chars().count();
if use_terminal_cursor {
spans.push(Span::styled(visible_chars, line_style));
let cx =
inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
let cy = inner_area.y + display_row;
if cx < inner_area.x + inner_area.width
&& cy < inner_area.y + inner_area.height
{
cursor_screen_pos = Some((cx, cy));
}
} else if cursor_visible_col <= visible_char_count {
let before: String =
visible_chars.chars().take(cursor_visible_col).collect();
let cursor_char: String = visible_chars
.chars()
.skip(cursor_visible_col)
.take(1)
.collect();
let after: String =
visible_chars.chars().skip(cursor_visible_col + 1).collect();
if !before.is_empty() {
spans.push(Span::styled(before, line_style));
}
let cursor_style = Style::default()
.fg(self.style.cursor_fg)
.bg(self.style.text_fg);
let cursor_display =
if cursor_char.is_empty() { " " } else { &cursor_char };
spans.push(Span::styled(cursor_display.to_string(), cursor_style));
if !after.is_empty() {
spans.push(Span::styled(after, line_style));
}
} else {
spans.push(Span::styled(visible_chars, line_style));
}
} else {
spans.push(Span::styled(visible_chars, line_style));
}
display_lines.push(Line::from(spans));
}
} else {
let effective_scroll_y =
if self.style.scroll_mode == ScrollMode::CenterTracking && state.visible_height > 0 {
let total_lines = state.lines.len();
let half_height = state.visible_height / 2;
if total_lines <= state.visible_height || state.cursor_line <= half_height {
0
} else if state.cursor_line + half_height >= total_lines {
total_lines.saturating_sub(state.visible_height)
} else {
state.cursor_line.saturating_sub(half_height)
}
} else {
state.scroll_y
};
let start_line = effective_scroll_y;
let end_line = (start_line + state.visible_height).min(state.lines.len());
for line_idx in start_line..end_line {
let is_cursor_line = line_idx == state.cursor_line;
let display_row = (line_idx - start_line) as u16;
if let Some(ref content) = self.content_lines {
if line_idx < content.len() {
let mut spans = Vec::new();
if self.style.show_line_numbers {
let line_num = format!(
"{:>width$} ",
line_idx + 1,
width = line_num_width.saturating_sub(2)
);
spans.push(Span::styled(
line_num,
Style::default().fg(self.style.line_number_fg),
));
}
spans.extend(content[line_idx].spans.iter().cloned());
display_lines.push(Line::from(spans));
if is_cursor_line && state.focused && use_terminal_cursor {
let cursor_visible_col = state.cursor_col.saturating_sub(state.scroll_x);
let cx = inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
let cy = inner_area.y + display_row;
if cx < inner_area.x + inner_area.width
&& cy < inner_area.y + inner_area.height
{
cursor_screen_pos = Some((cx, cy));
}
}
continue;
}
}
let line = &state.lines[line_idx];
let chars: Vec<char> = line.chars().collect();
let visible_chars: String = chars
.iter()
.skip(state.scroll_x)
.take(content_width)
.collect();
let mut spans = Vec::new();
if self.style.show_line_numbers {
let line_num = format!(
"{:>width$} ",
line_idx + 1,
width = line_num_width.saturating_sub(2)
);
spans.push(Span::styled(
line_num,
Style::default().fg(self.style.line_number_fg),
));
}
let line_style = if is_cursor_line {
if let Some(bg) = self.style.current_line_bg {
Style::default().fg(self.style.text_fg).bg(bg)
} else {
Style::default().fg(self.style.text_fg)
}
} else {
Style::default().fg(self.style.text_fg)
};
if is_cursor_line && state.focused {
let cursor_visible_col = state.cursor_col.saturating_sub(state.scroll_x);
let visible_char_count = visible_chars.chars().count();
if use_terminal_cursor {
spans.push(Span::styled(visible_chars, line_style));
let cx = inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
let cy = inner_area.y + display_row;
if cx < inner_area.x + inner_area.width && cy < inner_area.y + inner_area.height
{
cursor_screen_pos = Some((cx, cy));
}
} else if cursor_visible_col <= visible_char_count {
let before: String = visible_chars.chars().take(cursor_visible_col).collect();
let cursor_char: String = visible_chars
.chars()
.skip(cursor_visible_col)
.take(1)
.collect();
let after: String =
visible_chars.chars().skip(cursor_visible_col + 1).collect();
if !before.is_empty() {
spans.push(Span::styled(before, line_style));
}
let cursor_style = Style::default()
.fg(self.style.cursor_fg)
.bg(self.style.text_fg);
let cursor_display = if cursor_char.is_empty() {
" "
} else {
&cursor_char
};
spans.push(Span::styled(cursor_display.to_string(), cursor_style));
if !after.is_empty() {
spans.push(Span::styled(after, line_style));
}
} else {
spans.push(Span::styled(visible_chars, line_style));
}
} else {
spans.push(Span::styled(visible_chars, line_style));
}
display_lines.push(Line::from(spans));
}
}
if display_lines.is_empty() && state.focused {
let mut spans = Vec::new();
if self.style.show_line_numbers {
let line_num = format!("{:>width$} ", 1, width = line_num_width.saturating_sub(2));
spans.push(Span::styled(
line_num,
Style::default().fg(self.style.line_number_fg),
));
}
if use_terminal_cursor {
spans.push(Span::styled(" ", Style::default().fg(self.style.text_fg)));
let cx = inner_area.x + line_num_width as u16;
let cy = inner_area.y;
cursor_screen_pos = Some((cx, cy));
} else {
let cursor_style = Style::default()
.fg(self.style.cursor_fg)
.bg(self.style.text_fg);
spans.push(Span::styled(" ", cursor_style));
}
display_lines.push(Line::from(spans));
}
let paragraph = Paragraph::new(display_lines);
if let Some(block) = block {
frame.render_widget(block, area);
}
frame.render_widget(paragraph, inner_area);
TextAreaRender {
click_region: ClickRegion::new(area, TextAreaAction::Focus),
cursor_position: cursor_screen_pos,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_default() {
let state = TextAreaState::default();
assert_eq!(state.lines.len(), 1);
assert!(state.lines[0].is_empty());
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
assert!(!state.focused);
assert!(state.enabled);
}
#[test]
fn test_state_new_single_line() {
let state = TextAreaState::new("Hello");
assert_eq!(state.lines.len(), 1);
assert_eq!(state.lines[0], "Hello");
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_state_new_multi_line() {
let state = TextAreaState::new("Hello\nWorld");
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "Hello");
assert_eq!(state.lines[1], "World");
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_state_new_empty() {
let state = TextAreaState::new("");
assert_eq!(state.lines.len(), 1);
assert!(state.lines[0].is_empty());
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_state_empty() {
let state = TextAreaState::empty();
assert!(state.is_empty());
}
#[test]
fn test_insert_char() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.insert_char('!');
assert_eq!(state.lines[0], "Hello!");
assert_eq!(state.cursor_col, 6);
}
#[test]
fn test_insert_char_middle() {
let mut state = TextAreaState::new("Hllo");
state.cursor_col = 1;
state.insert_char('e');
assert_eq!(state.lines[0], "Hello");
assert_eq!(state.cursor_col, 2);
}
#[test]
fn test_insert_str_single_line() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.insert_str(" World");
assert_eq!(state.lines[0], "Hello World");
}
#[test]
fn test_insert_str_multi_line() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.insert_str(" World\nNew Line");
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "Hello World");
assert_eq!(state.lines[1], "New Line");
}
#[test]
fn test_insert_newline() {
let mut state = TextAreaState::new("HelloWorld");
state.cursor_col = 5;
state.insert_newline();
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "Hello");
assert_eq!(state.lines[1], "World");
assert_eq!(state.cursor_line, 1);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_insert_newline_at_start() {
let mut state = TextAreaState::new("Hello");
state.insert_newline();
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "");
assert_eq!(state.lines[1], "Hello");
}
#[test]
fn test_insert_newline_at_end() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.insert_newline();
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "Hello");
assert_eq!(state.lines[1], "");
}
#[test]
fn test_insert_tab_spaces() {
let mut state = TextAreaState::empty();
state.tab_config = TabConfig::Spaces(4);
state.insert_tab();
assert_eq!(state.lines[0], " ");
}
#[test]
fn test_insert_tab_literal() {
let mut state = TextAreaState::empty();
state.tab_config = TabConfig::Literal;
state.insert_tab();
assert_eq!(state.lines[0], "\t");
}
#[test]
fn test_delete_char_backward() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
assert!(state.delete_char_backward());
assert_eq!(state.lines[0], "Hell");
assert_eq!(state.cursor_col, 4);
}
#[test]
fn test_delete_char_backward_at_start() {
let mut state = TextAreaState::new("Hello");
assert!(!state.delete_char_backward());
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_delete_char_backward_merges_lines() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 1;
state.cursor_col = 0;
assert!(state.delete_char_backward());
assert_eq!(state.lines.len(), 1);
assert_eq!(state.lines[0], "HelloWorld");
assert_eq!(state.cursor_col, 5);
}
#[test]
fn test_delete_char_forward() {
let mut state = TextAreaState::new("Hello");
state.cursor_col = 0;
assert!(state.delete_char_forward());
assert_eq!(state.lines[0], "ello");
}
#[test]
fn test_delete_char_forward_at_end() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
assert!(!state.delete_char_forward());
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_delete_char_forward_merges_lines() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 0;
state.cursor_col = 5;
assert!(state.delete_char_forward());
assert_eq!(state.lines.len(), 1);
assert_eq!(state.lines[0], "HelloWorld");
}
#[test]
fn test_delete_word_backward() {
let mut state = TextAreaState::new("Hello World");
state.move_to_end();
assert!(state.delete_word_backward());
assert_eq!(state.lines[0], "Hello ");
}
#[test]
fn test_delete_word_backward_from_start() {
let mut state = TextAreaState::new("Hello");
assert!(!state.delete_word_backward());
}
#[test]
fn test_delete_line() {
let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
state.cursor_line = 1;
state.cursor_col = 0;
state.delete_line();
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "Line 1");
assert_eq!(state.lines[1], "Line 3");
}
#[test]
fn test_delete_line_single() {
let mut state = TextAreaState::new("Hello");
state.delete_line();
assert_eq!(state.lines.len(), 1);
assert!(state.lines[0].is_empty());
}
#[test]
fn test_delete_to_line_start() {
let mut state = TextAreaState::new("Hello World");
state.cursor_col = 6;
state.delete_to_line_start();
assert_eq!(state.lines[0], "World");
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_delete_to_line_end() {
let mut state = TextAreaState::new("Hello World");
state.cursor_col = 5;
state.delete_to_line_end();
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_move_left() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.move_left();
assert_eq!(state.cursor_col, 4);
}
#[test]
fn test_move_left_wraps_line() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 1;
state.cursor_col = 0;
state.move_left();
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 5);
}
#[test]
fn test_move_left_at_start() {
let mut state = TextAreaState::new("Hello");
state.cursor_col = 0;
state.move_left();
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_move_right() {
let mut state = TextAreaState::new("Hello");
state.cursor_col = 0;
state.move_right();
assert_eq!(state.cursor_col, 1);
}
#[test]
fn test_move_right_wraps_line() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 0;
state.cursor_col = 5;
state.move_right();
assert_eq!(state.cursor_line, 1);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_move_right_at_end() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
state.move_right();
assert_eq!(state.cursor_col, 5); }
#[test]
fn test_move_line_start() {
let mut state = TextAreaState::new("Hello");
state.move_line_start();
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_move_line_end() {
let mut state = TextAreaState::new("Hello");
state.cursor_col = 0;
state.move_line_end();
assert_eq!(state.cursor_col, 5);
}
#[test]
fn test_move_up() {
let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
state.cursor_line = 2; state.move_up();
assert_eq!(state.cursor_line, 1);
}
#[test]
fn test_move_up_clamps_column() {
let mut state = TextAreaState::new("AB\nCDEFG");
state.cursor_line = 1; state.cursor_col = 5;
state.move_up();
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 2); }
#[test]
fn test_move_down() {
let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
state.cursor_line = 0;
state.move_down();
assert_eq!(state.cursor_line, 1);
}
#[test]
fn test_move_down_at_last_line() {
let mut state = TextAreaState::new("Hello");
state.move_down();
assert_eq!(state.cursor_line, 0);
}
#[test]
fn test_move_word_left() {
let mut state = TextAreaState::new("Hello World Test");
state.move_to_end(); state.move_word_left();
assert_eq!(state.cursor_col, 12); }
#[test]
fn test_move_word_right() {
let mut state = TextAreaState::new("Hello World Test");
state.cursor_col = 0;
state.move_word_right();
assert_eq!(state.cursor_col, 6); }
#[test]
fn test_move_page_up() {
let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
state.visible_height = 3;
state.cursor_line = 9; state.move_page_up();
assert_eq!(state.cursor_line, 6);
}
#[test]
fn test_move_page_down() {
let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
state.cursor_line = 0;
state.visible_height = 3;
state.move_page_down();
assert_eq!(state.cursor_line, 3);
}
#[test]
fn test_move_to_start() {
let mut state = TextAreaState::new("Hello\nWorld");
state.move_to_start();
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_move_to_end() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 0;
state.cursor_col = 0;
state.move_to_end();
assert_eq!(state.cursor_line, 1);
assert_eq!(state.cursor_col, 5);
}
#[test]
fn test_text() {
let state = TextAreaState::new("Hello\nWorld");
assert_eq!(state.text(), "Hello\nWorld");
}
#[test]
fn test_set_text() {
let mut state = TextAreaState::new("Old");
state.set_text("New\nContent");
assert_eq!(state.lines.len(), 2);
assert_eq!(state.lines[0], "New");
assert_eq!(state.lines[1], "Content");
assert_eq!(state.cursor_line, 1);
assert_eq!(state.cursor_col, 7);
}
#[test]
fn test_clear() {
let mut state = TextAreaState::new("Hello\nWorld");
state.clear();
assert!(state.is_empty());
assert_eq!(state.cursor_line, 0);
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_line_count() {
let state = TextAreaState::new("A\nB\nC");
assert_eq!(state.line_count(), 3);
}
#[test]
fn test_current_line() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_line = 0;
assert_eq!(state.current_line(), "Hello");
}
#[test]
fn test_is_empty() {
let state = TextAreaState::empty();
assert!(state.is_empty());
let state = TextAreaState::new("Hi");
assert!(!state.is_empty());
}
#[test]
fn test_len() {
let state = TextAreaState::new("Hi\nWorld");
assert_eq!(state.len(), 8);
}
#[test]
fn test_text_before_after_cursor() {
let mut state = TextAreaState::new("Hello World");
state.cursor_col = 5;
assert_eq!(state.text_before_cursor(), "Hello");
assert_eq!(state.text_after_cursor(), " World");
}
#[test]
fn test_unicode_handling() {
let mut state = TextAreaState::new("ä½ å¥½");
state.move_to_end();
assert_eq!(state.cursor_col, 2);
state.move_left();
assert_eq!(state.cursor_col, 1);
state.insert_char('世');
assert_eq!(state.lines[0], "ä½ ä¸–å¥½");
}
#[test]
fn test_emoji_handling() {
let mut state = TextAreaState::new("Hi 👋");
assert_eq!(state.len(), 4);
state.move_to_end();
state.delete_char_backward();
assert_eq!(state.lines[0], "Hi ");
}
#[test]
fn test_disabled_no_insert() {
let mut state = TextAreaState::new("Hello");
state.enabled = false;
state.insert_char('!');
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_disabled_no_delete() {
let mut state = TextAreaState::new("Hello");
state.enabled = false;
assert!(!state.delete_char_backward());
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_disabled_no_newline() {
let mut state = TextAreaState::new("Hello");
state.enabled = false;
state.insert_newline();
assert_eq!(state.lines.len(), 1);
}
#[test]
fn test_scroll_to_cursor_down() {
let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
state.visible_height = 3;
state.cursor_line = 8;
state.scroll_to_cursor();
assert_eq!(state.scroll_y, 6);
}
#[test]
fn test_scroll_to_cursor_up() {
let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
state.visible_height = 3;
state.scroll_y = 5;
state.cursor_line = 2;
state.scroll_to_cursor();
assert_eq!(state.scroll_y, 2);
}
#[test]
fn test_scroll_up() {
let mut state = TextAreaState::new("1\n2\n3");
state.scroll_y = 2;
state.scroll_up();
assert_eq!(state.scroll_y, 1);
}
#[test]
fn test_scroll_down() {
let mut state = TextAreaState::new("1\n2\n3\n4\n5");
state.visible_height = 2;
state.scroll_down();
assert_eq!(state.scroll_y, 1);
}
#[test]
fn test_style_default() {
let style = TextAreaStyle::default();
assert_eq!(style.focused_border, Color::Yellow);
assert_eq!(style.text_fg, Color::White);
assert!(!style.show_line_numbers);
}
#[test]
fn test_style_builder() {
let style = TextAreaStyle::default()
.focused_border(Color::Cyan)
.text_fg(Color::Green)
.show_line_numbers(true);
assert_eq!(style.focused_border, Color::Cyan);
assert_eq!(style.text_fg, Color::Green);
assert!(style.show_line_numbers);
}
#[test]
fn test_tab_config_default() {
let config = TabConfig::default();
assert_eq!(config, TabConfig::Spaces(4));
}
#[test]
fn test_with_tab_config() {
let state = TextAreaState::empty().with_tab_config(TabConfig::Spaces(2));
assert_eq!(state.tab_config, TabConfig::Spaces(2));
}
#[test]
fn test_delete_word_forward() {
let mut state = TextAreaState::new("Hello World Test");
state.cursor_col = 0;
assert!(state.delete_word_forward());
assert_eq!(state.lines[0], "World Test");
assert_eq!(state.cursor_col, 0);
}
#[test]
fn test_delete_word_forward_mid_word() {
let mut state = TextAreaState::new("Hello World");
state.cursor_col = 3; assert!(state.delete_word_forward());
assert_eq!(state.lines[0], "HelWorld");
}
#[test]
fn test_delete_word_forward_at_end() {
let mut state = TextAreaState::new("Hello");
state.move_to_end();
assert!(!state.delete_word_forward());
assert_eq!(state.lines[0], "Hello");
}
#[test]
fn test_delete_word_forward_merges_lines() {
let mut state = TextAreaState::new("Hello\nWorld");
state.cursor_col = 5; assert!(state.delete_word_forward());
assert_eq!(state.lines.len(), 1);
assert_eq!(state.lines[0], "HelloWorld");
}
#[test]
fn test_cursor_mode_default() {
assert_eq!(CursorMode::default(), CursorMode::Block);
}
#[test]
fn test_scroll_mode_default() {
assert_eq!(ScrollMode::default(), ScrollMode::Minimal);
}
#[test]
fn test_style_cursor_mode() {
let style = TextAreaStyle::default().cursor_mode(CursorMode::Terminal);
assert_eq!(style.cursor_mode, CursorMode::Terminal);
}
#[test]
fn test_style_scroll_mode() {
let style = TextAreaStyle::default().scroll_mode(ScrollMode::CenterTracking);
assert_eq!(style.scroll_mode, ScrollMode::CenterTracking);
}
#[test]
fn test_textarea_title_builder() {
let textarea = TextArea::new().title(Line::from("My Title"));
assert!(textarea.title.is_some());
}
#[test]
fn test_textarea_border_color_builder() {
let textarea = TextArea::new().border_color(Color::Red);
assert_eq!(textarea.border_color_override, Some(Color::Red));
}
#[test]
fn test_textarea_content_lines_builder() {
let lines = vec![Line::from("test")];
let textarea = TextArea::new().content_lines(lines);
assert!(textarea.content_lines.is_some());
}
}