use ratatui::widgets::{Block, Borders, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::undo::UndoStack;
#[cfg(feature = "clipboard")]
use crate::clipboard::system_clipboard_get;
mod cursor;
mod search;
mod selection;
mod update;
#[derive(Debug, Clone)]
struct TextAreaSnapshot {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TextAreaMessage {
Insert(char),
NewLine,
Backspace,
Delete,
Left,
Right,
Up,
Down,
Home,
End,
TextStart,
TextEnd,
WordLeft,
WordRight,
SelectLeft,
SelectRight,
SelectUp,
SelectDown,
SelectHome,
SelectEnd,
SelectWordLeft,
SelectWordRight,
SelectAll,
Copy,
Cut,
Paste(String),
DeleteLine,
DeleteToEnd,
DeleteToStart,
Clear,
SetValue(String),
Submit,
Undo,
Redo,
StartSearch,
SetSearchQuery(String),
NextMatch,
PrevMatch,
ClearSearch,
ToggleLineNumbers,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TextAreaOutput {
Submitted(String),
Changed(String),
Copied(String),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct TextAreaState {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
scroll_offset: usize,
placeholder: String,
selection_anchor: Option<(usize, usize)>,
clipboard: String,
#[cfg_attr(feature = "serialization", serde(skip))]
undo_stack: UndoStack<TextAreaSnapshot>,
show_line_numbers: bool,
search_query: Option<String>,
search_matches: Vec<(usize, usize)>,
current_match: usize,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
scroll_offset: 0,
placeholder: String::new(),
selection_anchor: None,
clipboard: String::new(),
undo_stack: UndoStack::default(),
show_line_numbers: false,
search_query: None,
search_matches: Vec::new(),
current_match: 0,
}
}
}
impl TextAreaState {
pub fn new() -> Self {
Self::default()
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
let value = value.into();
self.lines = if value.is_empty() {
vec![String::new()]
} else {
value.split('\n').map(String::from).collect()
};
self.cursor_row = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines.last().map(|l| l.len()).unwrap_or(0);
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn value(&self) -> String {
self.lines.join("\n")
}
pub fn set_value(&mut self, value: impl Into<String>) {
let value = value.into();
self.lines = if value.is_empty() {
vec![String::new()]
} else {
value.split('\n').map(String::from).collect()
};
self.cursor_row = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines[self.cursor_row].len();
self.scroll_offset = 0;
self.selection_anchor = None;
}
pub fn cursor_position(&self) -> (usize, usize) {
let char_col = self.lines[self.cursor_row][..self.cursor_col]
.chars()
.count();
(self.cursor_row, char_col)
}
pub fn cursor_display_position(&self) -> (usize, usize) {
let display_col = self.lines[self.cursor_row][..self.cursor_col].width();
(self.cursor_row, display_col)
}
pub fn cursor_row(&self) -> usize {
self.cursor_row
}
pub fn cursor_col(&self) -> usize {
self.cursor_col
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn line(&self, index: usize) -> Option<&str> {
self.lines.get(index).map(|s| s.as_str())
}
pub fn current_line(&self) -> &str {
&self.lines[self.cursor_row]
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_cursor_position(&mut self, row: usize, col: usize) {
self.cursor_row = row.min(self.lines.len().saturating_sub(1));
let line = &self.lines[self.cursor_row];
let char_count = line.chars().count();
let clamped_col = col.min(char_count);
self.cursor_col = line
.char_indices()
.nth(clamped_col)
.map(|(i, _)| i)
.unwrap_or(line.len());
}
pub fn ensure_cursor_visible(&mut self, visible_lines: usize) {
if visible_lines == 0 {
return;
}
if self.cursor_row < self.scroll_offset {
self.scroll_offset = self.cursor_row;
}
if self.cursor_row >= self.scroll_offset + visible_lines {
self.scroll_offset = self.cursor_row - visible_lines + 1;
}
}
pub fn can_undo(&self) -> bool {
self.undo_stack.can_undo()
}
pub fn can_redo(&self) -> bool {
self.undo_stack.can_redo()
}
fn snapshot(&self) -> TextAreaSnapshot {
TextAreaSnapshot {
lines: self.lines.clone(),
cursor_row: self.cursor_row,
cursor_col: self.cursor_col,
}
}
fn restore(&mut self, snapshot: TextAreaSnapshot) {
self.lines = snapshot.lines;
self.cursor_row = snapshot.cursor_row;
self.cursor_col = snapshot.cursor_col;
self.clear_selection();
}
pub fn with_line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
self
}
pub fn show_line_numbers(&self) -> bool {
self.show_line_numbers
}
pub fn set_show_line_numbers(&mut self, show: bool) {
self.show_line_numbers = show;
}
pub fn update(&mut self, msg: TextAreaMessage) -> Option<TextAreaOutput> {
TextArea::update(self, msg)
}
}
pub struct TextArea;
impl Component for TextArea {
type State = TextAreaState;
type Message = TextAreaMessage;
type Output = TextAreaOutput;
fn init() -> Self::State {
TextAreaState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Event::Paste(text) = event {
return Some(TextAreaMessage::Paste(text.clone()));
}
if let Some(key) = event.as_key() {
let ctrl = key.modifiers.ctrl();
let shift = key.modifiers.shift();
match key.code {
Key::Char('z') if ctrl => Some(TextAreaMessage::Undo),
Key::Char('y') if ctrl => Some(TextAreaMessage::Redo),
Key::Char('c') if ctrl => Some(TextAreaMessage::Copy),
Key::Char('x') if ctrl => Some(TextAreaMessage::Cut),
Key::Char('v') if ctrl => {
#[cfg(feature = "clipboard")]
if let Some(text) = system_clipboard_get() {
return Some(TextAreaMessage::Paste(text));
}
if state.clipboard.is_empty() {
None
} else {
Some(TextAreaMessage::Paste(state.clipboard.clone()))
}
}
Key::Char('a') if ctrl => Some(TextAreaMessage::SelectAll),
Key::Char(_) if !ctrl => key.raw_char.map(TextAreaMessage::Insert),
Key::Enter => Some(TextAreaMessage::NewLine),
Key::Left if ctrl && shift => Some(TextAreaMessage::SelectWordLeft),
Key::Right if ctrl && shift => Some(TextAreaMessage::SelectWordRight),
Key::Left if shift => Some(TextAreaMessage::SelectLeft),
Key::Right if shift => Some(TextAreaMessage::SelectRight),
Key::Up if shift => Some(TextAreaMessage::SelectUp),
Key::Down if shift => Some(TextAreaMessage::SelectDown),
Key::Home if shift => Some(TextAreaMessage::SelectHome),
Key::End if shift => Some(TextAreaMessage::SelectEnd),
Key::Backspace if ctrl => Some(TextAreaMessage::DeleteLine),
Key::Backspace => Some(TextAreaMessage::Backspace),
Key::Delete => Some(TextAreaMessage::Delete),
Key::Left if ctrl => Some(TextAreaMessage::WordLeft),
Key::Right if ctrl => Some(TextAreaMessage::WordRight),
Key::Left => Some(TextAreaMessage::Left),
Key::Right => Some(TextAreaMessage::Right),
Key::Up => Some(TextAreaMessage::Up),
Key::Down => Some(TextAreaMessage::Down),
Key::Home if ctrl => Some(TextAreaMessage::TextStart),
Key::End if ctrl => Some(TextAreaMessage::TextEnd),
Key::Home => Some(TextAreaMessage::Home),
Key::End => Some(TextAreaMessage::End),
Key::Char('k') if ctrl => Some(TextAreaMessage::DeleteToEnd),
Key::Char('u') if ctrl => Some(TextAreaMessage::DeleteToStart),
_ => None,
}
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
state.apply_update(msg)
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
let first_line = state.lines.first().map_or("", |l| l.as_str());
reg.register(
ctx.area,
crate::annotation::Annotation::text_area("text_area")
.with_value(first_line)
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let inner_height = ctx.area.height.saturating_sub(2) as usize;
let mut scroll = state.scroll_offset;
if inner_height > 0 {
if state.cursor_row < scroll {
scroll = state.cursor_row;
}
if state.cursor_row >= scroll + inner_height {
scroll = state.cursor_row - inner_height + 1;
}
}
let display_text = if state.is_empty() && !state.placeholder.is_empty() {
state.placeholder.clone()
} else {
state
.lines
.iter()
.skip(scroll)
.take(inner_height.max(1))
.cloned()
.collect::<Vec<_>>()
.join("\n")
};
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else if state.is_empty() && !state.placeholder.is_empty() {
ctx.theme.placeholder_style()
} else {
ctx.theme.normal_style()
};
let border_style = if ctx.focused && !ctx.disabled {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let paragraph = Paragraph::new(display_text).style(style).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
ctx.frame.render_widget(paragraph, ctx.area);
if ctx.focused && ctx.area.width > 2 && ctx.area.height > 2 {
let cursor_row_in_view = state.cursor_row.saturating_sub(scroll);
let (_, display_col) = state.cursor_display_position();
let cursor_x = ctx.area.x + 1 + display_col as u16;
let cursor_y = ctx.area.y + 1 + cursor_row_in_view as u16;
if cursor_x < ctx.area.x + ctx.area.width - 1
&& cursor_y < ctx.area.y + ctx.area.height - 1
&& cursor_row_in_view < inner_height
{
ctx.frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod undo_tests;