use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::undo::{EditKind, UndoStack};
mod editing;
#[cfg(feature = "clipboard")]
use crate::clipboard::{system_clipboard_get, system_clipboard_set};
#[derive(Debug, Clone)]
struct InputSnapshot {
value: String,
cursor: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InputFieldMessage {
Insert(char),
Backspace,
Delete,
Left,
Right,
Home,
End,
WordLeft,
WordRight,
SelectLeft,
SelectRight,
SelectHome,
SelectEnd,
SelectWordLeft,
SelectWordRight,
SelectAll,
Copy,
Cut,
Paste(String),
DeleteWordBack,
DeleteWordForward,
Clear,
SetValue(String),
Submit,
Undo,
Redo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InputFieldOutput {
Submitted(String),
Changed(String),
Copied(String),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct InputFieldState {
value: String,
cursor: usize,
placeholder: String,
selection_anchor: Option<usize>,
clipboard: String,
#[cfg_attr(feature = "serialization", serde(skip))]
undo_stack: UndoStack<InputSnapshot>,
}
impl InputFieldState {
pub fn new() -> Self {
Self::default()
}
pub fn with_value(value: impl Into<String>) -> Self {
let value = value.into();
let cursor = value.len();
Self {
value,
cursor,
placeholder: String::new(),
selection_anchor: None,
clipboard: String::new(),
undo_stack: UndoStack::default(),
}
}
pub fn with_placeholder(placeholder: impl Into<String>) -> Self {
Self {
value: String::new(),
cursor: 0,
placeholder: placeholder.into(),
selection_anchor: None,
clipboard: String::new(),
undo_stack: UndoStack::default(),
}
}
pub fn value(&self) -> &str {
&self.value
}
pub fn set_value(&mut self, value: impl Into<String>) {
self.value = value.into();
self.cursor = self.value.len();
self.selection_anchor = None;
}
pub fn cursor_position(&self) -> usize {
self.value[..self.cursor].chars().count()
}
pub fn cursor_display_position(&self) -> usize {
self.value[..self.cursor].width()
}
pub fn cursor_byte_offset(&self) -> usize {
self.cursor
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn len(&self) -> usize {
self.value.chars().count()
}
pub fn set_cursor_position(&mut self, char_pos: usize) {
let char_count = self.value.chars().count();
let clamped = char_pos.min(char_count);
self.cursor = self
.value
.char_indices()
.nth(clamped)
.map(|(i, _)| i)
.unwrap_or(self.value.len());
}
pub fn has_selection(&self) -> bool {
self.selection_anchor.is_some() && self.selection_anchor != Some(self.cursor)
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
self.selection_anchor.and_then(|anchor| {
if anchor == self.cursor {
None
} else {
let start = anchor.min(self.cursor);
let end = anchor.max(self.cursor);
Some((start, end))
}
})
}
pub fn selected_text(&self) -> Option<&str> {
self.selection_range()
.map(|(start, end)| &self.value[start..end])
}
pub fn clipboard(&self) -> &str {
&self.clipboard
}
fn clear_selection(&mut self) {
self.selection_anchor = None;
}
fn ensure_selection_anchor(&mut self) {
if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor);
}
}
fn delete_selection(&mut self) -> Option<String> {
let (start, end) = self.selection_range()?;
let deleted: String = self.value[start..end].to_string();
self.value.drain(start..end);
self.cursor = start;
self.selection_anchor = None;
Some(deleted)
}
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) -> InputSnapshot {
InputSnapshot {
value: self.value.clone(),
cursor: self.cursor,
}
}
fn restore(&mut self, snapshot: InputSnapshot) {
self.value = snapshot.value;
self.cursor = snapshot.cursor;
self.clear_selection();
}
pub fn update(&mut self, msg: InputFieldMessage) -> Option<InputFieldOutput> {
InputField::update(self, msg)
}
}
pub struct InputField;
impl Component for InputField {
type State = InputFieldState;
type Message = InputFieldMessage;
type Output = InputFieldOutput;
fn init() -> Self::State {
InputFieldState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
InputFieldMessage::Insert(c) => {
if c.is_whitespace() {
state.undo_stack.break_group();
}
let snapshot = state.snapshot();
state.undo_stack.save(snapshot, EditKind::Insert);
state.delete_selection();
state.insert(c);
if c.is_whitespace() {
state.undo_stack.break_group();
}
Some(InputFieldOutput::Changed(state.value.clone()))
}
InputFieldMessage::Backspace => {
let snapshot = state.snapshot();
if state.has_selection() {
state.delete_selection();
state.undo_stack.save(snapshot, EditKind::Delete);
Some(InputFieldOutput::Changed(state.value.clone()))
} else if state.backspace() {
state.undo_stack.save(snapshot, EditKind::Delete);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Delete => {
let snapshot = state.snapshot();
if state.has_selection() {
state.delete_selection();
state.undo_stack.save(snapshot, EditKind::Delete);
Some(InputFieldOutput::Changed(state.value.clone()))
} else if state.delete() {
state.undo_stack.save(snapshot, EditKind::Delete);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Left => {
if state.has_selection() {
if let Some((start, _)) = state.selection_range() {
state.cursor = start;
}
state.clear_selection();
} else {
state.move_left();
}
None
}
InputFieldMessage::Right => {
if state.has_selection() {
if let Some((_, end)) = state.selection_range() {
state.cursor = end;
}
state.clear_selection();
} else {
state.move_right();
}
None
}
InputFieldMessage::Home => {
state.clear_selection();
state.cursor = 0;
None
}
InputFieldMessage::End => {
state.clear_selection();
state.cursor = state.value.len();
None
}
InputFieldMessage::WordLeft => {
state.clear_selection();
state.move_word_left();
None
}
InputFieldMessage::WordRight => {
state.clear_selection();
state.move_word_right();
None
}
InputFieldMessage::SelectLeft => {
state.ensure_selection_anchor();
state.move_left();
None
}
InputFieldMessage::SelectRight => {
state.ensure_selection_anchor();
state.move_right();
None
}
InputFieldMessage::SelectHome => {
state.ensure_selection_anchor();
state.cursor = 0;
None
}
InputFieldMessage::SelectEnd => {
state.ensure_selection_anchor();
state.cursor = state.value.len();
None
}
InputFieldMessage::SelectWordLeft => {
state.ensure_selection_anchor();
state.move_word_left();
None
}
InputFieldMessage::SelectWordRight => {
state.ensure_selection_anchor();
state.move_word_right();
None
}
InputFieldMessage::SelectAll => {
if state.value.is_empty() {
return None;
}
state.selection_anchor = Some(0);
state.cursor = state.value.len();
None
}
InputFieldMessage::Copy => {
if let Some(text) = state.selected_text() {
let text = text.to_string();
state.clipboard = text.clone();
#[cfg(feature = "clipboard")]
system_clipboard_set(&text);
Some(InputFieldOutput::Copied(text))
} else {
None
}
}
InputFieldMessage::Cut => {
if let Some(text) = state.selected_text() {
let text = text.to_string();
let snapshot = state.snapshot();
state.clipboard = text.clone();
#[cfg(feature = "clipboard")]
system_clipboard_set(&text);
state.delete_selection();
state.undo_stack.save(snapshot, EditKind::Other);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Paste(text) => {
if text.is_empty() {
return None;
}
let snapshot = state.snapshot();
state.undo_stack.save(snapshot, EditKind::Other);
state.delete_selection();
for c in text.chars() {
state.insert(c);
}
Some(InputFieldOutput::Changed(state.value.clone()))
}
InputFieldMessage::DeleteWordBack => {
let snapshot = state.snapshot();
if state.has_selection() {
state.delete_selection();
state.undo_stack.save(snapshot, EditKind::Other);
Some(InputFieldOutput::Changed(state.value.clone()))
} else if state.delete_word_back() {
state.undo_stack.save(snapshot, EditKind::Other);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::DeleteWordForward => {
let snapshot = state.snapshot();
if state.has_selection() {
state.delete_selection();
state.undo_stack.save(snapshot, EditKind::Other);
Some(InputFieldOutput::Changed(state.value.clone()))
} else if state.delete_word_forward() {
state.undo_stack.save(snapshot, EditKind::Other);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Clear => {
state.clear_selection();
if !state.value.is_empty() {
let snapshot = state.snapshot();
state.undo_stack.save(snapshot, EditKind::Other);
state.value.clear();
state.cursor = 0;
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::SetValue(value) => {
if state.value != value {
let snapshot = state.snapshot();
state.undo_stack.save(snapshot, EditKind::Other);
state.set_value(value);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Submit => Some(InputFieldOutput::Submitted(state.value.clone())),
InputFieldMessage::Undo => {
let snapshot = state.snapshot();
if let Some(restored) = state.undo_stack.undo(snapshot) {
state.restore(restored);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
InputFieldMessage::Redo => {
let snapshot = state.snapshot();
if let Some(restored) = state.undo_stack.redo(snapshot) {
state.restore(restored);
Some(InputFieldOutput::Changed(state.value.clone()))
} else {
None
}
}
}
}
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(InputFieldMessage::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(InputFieldMessage::Undo),
Key::Char('y') if ctrl => Some(InputFieldMessage::Redo),
Key::Char('c') if ctrl => Some(InputFieldMessage::Copy),
Key::Char('x') if ctrl => Some(InputFieldMessage::Cut),
Key::Char('v') if ctrl => {
#[cfg(feature = "clipboard")]
if let Some(text) = system_clipboard_get() {
return Some(InputFieldMessage::Paste(text));
}
if state.clipboard.is_empty() {
None
} else {
Some(InputFieldMessage::Paste(state.clipboard.clone()))
}
}
Key::Char('a') if ctrl => Some(InputFieldMessage::SelectAll),
Key::Char(_) if !ctrl => key.raw_char.map(InputFieldMessage::Insert),
Key::Left if ctrl && shift => Some(InputFieldMessage::SelectWordLeft),
Key::Right if ctrl && shift => Some(InputFieldMessage::SelectWordRight),
Key::Left if shift => Some(InputFieldMessage::SelectLeft),
Key::Right if shift => Some(InputFieldMessage::SelectRight),
Key::Home if shift => Some(InputFieldMessage::SelectHome),
Key::End if shift => Some(InputFieldMessage::SelectEnd),
Key::Backspace if ctrl => Some(InputFieldMessage::DeleteWordBack),
Key::Delete if ctrl => Some(InputFieldMessage::DeleteWordForward),
Key::Backspace => Some(InputFieldMessage::Backspace),
Key::Delete => Some(InputFieldMessage::Delete),
Key::Left if ctrl => Some(InputFieldMessage::WordLeft),
Key::Right if ctrl => Some(InputFieldMessage::WordRight),
Key::Left => Some(InputFieldMessage::Left),
Key::Right => Some(InputFieldMessage::Right),
Key::Home => Some(InputFieldMessage::Home),
Key::End => Some(InputFieldMessage::End),
Key::Enter => Some(InputFieldMessage::Submit),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::input("input_field")
.with_value(state.value.as_str())
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let is_placeholder = state.value.is_empty() && !state.placeholder.is_empty();
let base_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else if is_placeholder {
ctx.theme.placeholder_style()
} else {
ctx.theme.normal_style()
};
let text = if is_placeholder {
&state.placeholder
} else {
&state.value
};
let line = if let Some((sel_start, sel_end)) = state.selection_range() {
let selection_style = ctx.theme.selection_style();
let before = &state.value[..sel_start];
let selected = &state.value[sel_start..sel_end];
let after = &state.value[sel_end..];
Line::from(vec![
Span::styled(before.to_string(), base_style),
Span::styled(selected.to_string(), selection_style),
Span::styled(after.to_string(), base_style),
])
} else {
Line::from(Span::styled(text.to_string(), base_style))
};
let paragraph = Paragraph::new(line).block(block);
ctx.frame.render_widget(paragraph, ctx.area);
if ctx.focused && ctx.area.width > 2 && ctx.area.height > 2 {
let cursor_x = ctx.area.x + 1 + state.cursor_display_position() as u16;
let cursor_y = ctx.area.y + 1;
if cursor_x < ctx.area.x + ctx.area.width - 1 {
ctx.frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod undo_tests;