#![forbid(unsafe_code)]
use ftui_core::event::{Event, ImeEvent, ImePhase, KeyCode, KeyEvent, KeyEventKind, Modifiers};
use ftui_core::geometry::Rect;
use ftui_render::cell::{Cell, CellContent};
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::grapheme_width;
use unicode_segmentation::UnicodeSegmentation;
use crate::undo_support::{TextEditOperation, TextInputUndoExt, UndoSupport, UndoWidgetId};
use crate::{Widget, clear_text_area};
#[derive(Debug, Clone, Default)]
pub struct TextInput {
undo_id: UndoWidgetId,
value: String,
cursor: usize,
scroll_cells: std::cell::Cell<usize>,
selection_anchor: Option<usize>,
ime_composition: Option<String>,
placeholder: String,
mask_char: Option<char>,
max_length: Option<usize>,
style: Style,
cursor_style: Style,
placeholder_style: Style,
selection_style: Style,
focused: bool,
}
impl TextInput {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = value.into();
self.cursor = self.value.graphemes(true).count();
self.selection_anchor = None;
self
}
#[must_use]
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn with_mask(mut self, mask: char) -> Self {
self.mask_char = Some(mask);
self
}
#[must_use]
pub fn with_max_length(mut self, max: usize) -> Self {
self.max_length = Some(max);
self
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_cursor_style(mut self, style: Style) -> Self {
self.cursor_style = style;
self
}
#[must_use]
pub fn with_placeholder_style(mut self, style: Style) -> Self {
self.placeholder_style = style;
self
}
#[must_use]
pub fn with_selection_style(mut self, style: Style) -> Self {
self.selection_style = style;
self
}
#[must_use]
pub fn with_focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn value(&self) -> &str {
&self.value
}
pub fn set_value(&mut self, value: impl Into<String>) {
self.value = value.into();
let max = self.grapheme_count();
self.cursor = self.cursor.min(max);
self.scroll_cells.set(0);
self.selection_anchor = None;
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
self.scroll_cells.set(0);
self.selection_anchor = None;
}
#[inline]
pub fn cursor(&self) -> usize {
self.cursor
}
#[inline]
pub fn focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn cursor_position(&self, area: Rect) -> (u16, u16) {
let cursor_visual = self.cursor_visual_pos();
let effective_scroll = self.effective_scroll(area.width as usize);
let rel_x = cursor_visual.saturating_sub(effective_scroll);
let x = area
.x
.saturating_add(rel_x as u16)
.min(area.right().saturating_sub(1));
(x, area.y)
}
#[must_use]
pub fn selected_text(&self) -> Option<&str> {
let anchor = self.selection_anchor?;
let (start, end) = self.selection_range(anchor);
let byte_start = self.grapheme_byte_offset(start);
let byte_end = self.grapheme_byte_offset(end);
Some(&self.value[byte_start..byte_end])
}
pub fn ime_start_composition(&mut self) {
if self.ime_composition.is_none() {
self.delete_selection();
}
self.ime_composition = Some(String::new());
#[cfg(feature = "tracing")]
self.trace_edit("ime_start");
}
pub fn ime_update_composition(&mut self, preedit: impl Into<String>) {
if self.ime_composition.is_none() {
self.delete_selection();
}
self.ime_composition = Some(preedit.into());
#[cfg(feature = "tracing")]
self.trace_edit("ime_update");
}
pub fn ime_commit_composition(&mut self) -> bool {
let Some(preedit) = self.ime_composition.take() else {
return false;
};
if !preedit.is_empty() {
self.insert_text(&preedit);
}
#[cfg(feature = "tracing")]
self.trace_edit("ime_commit");
true
}
pub fn ime_cancel_composition(&mut self) -> bool {
let cancelled = self.ime_composition.take().is_some();
#[cfg(feature = "tracing")]
if cancelled {
self.trace_edit("ime_cancel");
}
cancelled
}
#[must_use]
pub fn ime_composition(&self) -> Option<&str> {
self.ime_composition.as_deref()
}
pub fn handle_event(&mut self, event: &Event) -> bool {
let changed = match event {
Event::Key(key)
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
{
self.handle_key(key)
}
Event::Ime(ime) => self.handle_ime_event(ime),
Event::Paste(paste) => {
let had_selection = self.selection_anchor.is_some();
if had_selection {
let clean_text = Self::sanitize_input_text(&paste.text);
if let Some(max) = self.max_length {
let selection_len = if let Some(anchor) = self.selection_anchor {
let (start, end) = self.selection_range(anchor);
end.saturating_sub(start)
} else {
0
};
let available =
max.saturating_sub(self.grapheme_count().saturating_sub(selection_len));
if clean_text.graphemes(true).count() > available {
return false;
}
}
}
self.delete_selection();
self.insert_text(&paste.text);
true
}
_ => false,
};
#[cfg(feature = "tracing")]
if changed {
self.trace_edit(Self::event_operation_name(event));
}
changed
}
fn handle_ime_event(&mut self, ime: &ImeEvent) -> bool {
match ime.phase {
ImePhase::Start => {
self.ime_start_composition();
true
}
ImePhase::Update => {
self.ime_update_composition(&ime.text);
true
}
ImePhase::Commit => {
if self.ime_composition.is_some() {
self.ime_update_composition(&ime.text);
self.ime_commit_composition()
} else if !ime.text.is_empty() {
self.insert_text(&ime.text);
true
} else {
false
}
}
ImePhase::Cancel => self.ime_cancel_composition(),
}
}
fn handle_key(&mut self, key: &KeyEvent) -> bool {
let ctrl = key.modifiers.contains(Modifiers::CTRL);
let shift = key.modifiers.contains(Modifiers::SHIFT);
match key.code {
KeyCode::Char(c) if !ctrl => {
self.insert_char(c);
true
}
KeyCode::Char('a') if ctrl => {
self.select_all();
true
}
KeyCode::Char('w') if ctrl => {
self.delete_word_back();
true
}
KeyCode::Backspace => {
if self.selection_anchor.is_some() {
self.delete_selection();
} else if ctrl {
self.delete_word_back();
} else {
self.delete_char_back();
}
true
}
KeyCode::Delete => {
if self.selection_anchor.is_some() {
self.delete_selection();
} else if ctrl {
self.delete_word_forward();
} else {
self.delete_char_forward();
}
true
}
KeyCode::Left => {
if ctrl {
self.move_cursor_word_left(shift);
} else if shift {
self.move_cursor_left_select();
} else {
self.move_cursor_left();
}
true
}
KeyCode::Right => {
if ctrl {
self.move_cursor_word_right(shift);
} else if shift {
self.move_cursor_right_select();
} else {
self.move_cursor_right();
}
true
}
KeyCode::Home => {
if shift {
self.ensure_selection_anchor();
} else {
self.selection_anchor = None;
}
self.cursor = 0;
self.scroll_cells.set(0);
true
}
KeyCode::End => {
if shift {
self.ensure_selection_anchor();
} else {
self.selection_anchor = None;
}
self.cursor = self.grapheme_count();
true
}
_ => false,
}
}
#[cfg(feature = "tracing")]
fn trace_edit(&self, operation: &'static str) {
let _span = tracing::debug_span!(
"input.edit",
operation,
cursor_position = self.cursor,
grapheme_count = self.grapheme_count(),
has_selection = self.selection_anchor.is_some()
)
.entered();
}
#[cfg(feature = "tracing")]
fn event_operation_name(event: &Event) -> &'static str {
match event {
Event::Key(key) => Self::key_operation_name(key),
Event::Paste(_) => "paste",
Event::Ime(ime) => match ime.phase {
ImePhase::Start => "ime_start",
ImePhase::Update => "ime_update",
ImePhase::Commit => "ime_commit",
ImePhase::Cancel => "ime_cancel",
},
Event::Resize { .. } => "resize",
Event::Focus(_) => "focus",
Event::Mouse(_) => "mouse",
Event::Clipboard(_) => "clipboard",
Event::Tick => "tick",
}
}
#[cfg(feature = "tracing")]
fn key_operation_name(key: &KeyEvent) -> &'static str {
let ctrl = key.modifiers.contains(Modifiers::CTRL);
let shift = key.modifiers.contains(Modifiers::SHIFT);
match key.code {
KeyCode::Char(_) if !ctrl => "insert_char",
KeyCode::Char('a') if ctrl => "select_all",
KeyCode::Char('w') if ctrl => "delete_word_back",
KeyCode::Backspace if ctrl => "delete_word_back",
KeyCode::Backspace => "delete_back",
KeyCode::Delete if ctrl => "delete_word_forward",
KeyCode::Delete => "delete_forward",
KeyCode::Left if ctrl && shift => "move_word_left_select",
KeyCode::Left if ctrl => "move_word_left",
KeyCode::Left if shift => "move_left_select",
KeyCode::Left => "move_left",
KeyCode::Right if ctrl && shift => "move_word_right_select",
KeyCode::Right if ctrl => "move_word_right",
KeyCode::Right if shift => "move_right_select",
KeyCode::Right => "move_right",
KeyCode::Home if shift => "move_home_select",
KeyCode::Home => "move_home",
KeyCode::End if shift => "move_end_select",
KeyCode::End => "move_end",
_ => "key_other",
}
}
fn sanitize_input_text(text: &str) -> String {
text.chars()
.map(|c| {
if c == '\n' || c == '\r' || c == '\t' {
' '
} else {
c
}
})
.filter(|c| !c.is_control())
.collect()
}
pub fn insert_text(&mut self, text: &str) {
self.delete_selection();
let clean_text = Self::sanitize_input_text(text);
if clean_text.is_empty() {
return;
}
let current_count = self.grapheme_count();
let avail = if let Some(max) = self.max_length {
if current_count >= max {
1
} else {
max - current_count
}
} else {
usize::MAX
};
let new_graphemes = clean_text.graphemes(true).count();
let to_insert = if new_graphemes > avail {
let end_byte = clean_text
.grapheme_indices(true)
.map(|(i, _)| i)
.nth(avail)
.unwrap_or(clean_text.len());
&clean_text[..end_byte]
} else {
clean_text.as_str()
};
if to_insert.is_empty() {
return;
}
let byte_offset = self.grapheme_byte_offset(self.cursor);
self.value.insert_str(byte_offset, to_insert);
let new_total = self.grapheme_count();
if let Some(max) = self.max_length
&& new_total > max
{
self.value.drain(byte_offset..byte_offset + to_insert.len());
return;
}
self.cursor = self
.value
.grapheme_indices(true)
.take_while(|(i, _)| *i < byte_offset + to_insert.len())
.count();
}
fn insert_char(&mut self, c: char) {
if c.is_control() {
return;
}
self.delete_selection();
let byte_offset = self.grapheme_byte_offset(self.cursor);
self.value.insert(byte_offset, c);
let new_count = self.grapheme_count();
if let Some(max) = self.max_length
&& new_count > max
{
let char_len = c.len_utf8();
self.value.drain(byte_offset..byte_offset + char_len);
return;
}
let char_len = c.len_utf8();
self.cursor = self
.value
.grapheme_indices(true)
.take_while(|(i, _)| *i < byte_offset + char_len)
.count();
}
fn delete_char_back(&mut self) {
if self.cursor > 0 {
let byte_start = self.grapheme_byte_offset(self.cursor - 1);
let byte_end = self.grapheme_byte_offset(self.cursor);
self.value.drain(byte_start..byte_end);
self.cursor -= 1;
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
fn delete_char_forward(&mut self) {
let count = self.grapheme_count();
if self.cursor < count {
let byte_start = self.grapheme_byte_offset(self.cursor);
let byte_end = self.grapheme_byte_offset(self.cursor + 1);
self.value.drain(byte_start..byte_end);
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
fn delete_word_back(&mut self) {
if self.cursor == 0 {
return;
}
let old_cursor = self.cursor;
self.move_cursor_word_left(false);
let new_cursor = self.cursor;
if new_cursor < old_cursor {
let byte_start = self.grapheme_byte_offset(new_cursor);
let byte_end = self.grapheme_byte_offset(old_cursor);
self.value.drain(byte_start..byte_end);
self.cursor = new_cursor;
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
fn delete_word_forward(&mut self) {
let old_cursor = self.cursor;
self.move_cursor_word_right(false);
let new_cursor = self.cursor;
self.cursor = old_cursor;
if new_cursor > old_cursor {
let byte_start = self.grapheme_byte_offset(old_cursor);
let byte_end = self.grapheme_byte_offset(new_cursor);
self.value.drain(byte_start..byte_end);
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
pub fn select_all(&mut self) {
self.selection_anchor = Some(0);
self.cursor = self.grapheme_count();
}
fn delete_selection(&mut self) {
if let Some(anchor) = self.selection_anchor.take() {
let (start, end) = self.selection_range(anchor);
let byte_start = self.grapheme_byte_offset(start);
let byte_end = self.grapheme_byte_offset(end);
self.value.drain(byte_start..byte_end);
self.cursor = start;
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
fn ensure_selection_anchor(&mut self) {
if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor);
}
}
fn selection_range(&self, anchor: usize) -> (usize, usize) {
if anchor <= self.cursor {
(anchor, self.cursor)
} else {
(self.cursor, anchor)
}
}
fn is_in_selection(&self, grapheme_idx: usize) -> bool {
if let Some(anchor) = self.selection_anchor {
let (start, end) = self.selection_range(anchor);
grapheme_idx >= start && grapheme_idx < end
} else {
false
}
}
fn move_cursor_left(&mut self) {
if let Some(anchor) = self.selection_anchor.take() {
self.cursor = self.cursor.min(anchor);
} else if self.cursor > 0 {
self.cursor -= 1;
}
}
fn move_cursor_right(&mut self) {
if let Some(anchor) = self.selection_anchor.take() {
self.cursor = self.cursor.max(anchor);
} else if self.cursor < self.grapheme_count() {
self.cursor += 1;
}
}
fn move_cursor_left_select(&mut self) {
self.ensure_selection_anchor();
if self.cursor > 0 {
self.cursor -= 1;
}
}
fn move_cursor_right_select(&mut self) {
self.ensure_selection_anchor();
if self.cursor < self.grapheme_count() {
self.cursor += 1;
}
}
fn get_grapheme_class(g: &str) -> u8 {
if g.chars().all(char::is_whitespace) {
0
} else if g.chars().any(char::is_alphanumeric) {
1
} else {
2
}
}
fn move_cursor_word_left(&mut self, select: bool) {
if select {
self.ensure_selection_anchor();
} else {
self.selection_anchor = None;
}
if self.cursor == 0 {
return;
}
let byte_offset = self.grapheme_byte_offset(self.cursor);
let before_cursor = &self.value[..byte_offset];
let mut pos = self.cursor;
let mut iter = before_cursor.graphemes(true).rev();
while let Some(g) = iter.next() {
if Self::get_grapheme_class(g) == 0 {
pos = pos.saturating_sub(1);
} else {
pos = pos.saturating_sub(1);
let target = Self::get_grapheme_class(g);
for g_next in iter {
if Self::get_grapheme_class(g_next) == target {
pos = pos.saturating_sub(1);
} else {
break;
}
}
break;
}
}
self.cursor = pos;
}
fn move_cursor_word_right(&mut self, select: bool) {
if select {
self.ensure_selection_anchor();
} else {
self.selection_anchor = None;
}
let mut iter = self.value.graphemes(true).peekable();
for _ in 0..self.cursor {
iter.next();
}
let mut pos = self.cursor;
if let Some(&g) = iter.peek() {
let start_class = Self::get_grapheme_class(g);
if start_class != 0 {
while let Some(&next_g) = iter.peek() {
if Self::get_grapheme_class(next_g) == start_class {
iter.next();
pos += 1;
} else {
break;
}
}
}
}
while let Some(&g) = iter.peek() {
if Self::get_grapheme_class(g) == 0 {
iter.next();
pos += 1;
} else {
break;
}
}
self.cursor = pos;
}
fn grapheme_count(&self) -> usize {
self.value.graphemes(true).count()
}
fn grapheme_byte_offset(&self, grapheme_idx: usize) -> usize {
self.value
.grapheme_indices(true)
.nth(grapheme_idx)
.map(|(i, _)| i)
.unwrap_or(self.value.len())
}
fn grapheme_width(&self, g: &str) -> usize {
if let Some(mask) = self.mask_char {
let mut buf = [0u8; 4];
let mask_str = mask.encode_utf8(&mut buf);
grapheme_width(mask_str)
} else {
grapheme_width(g)
}
}
fn prev_grapheme_width(&self) -> usize {
if self.cursor == 0 {
return 0;
}
self.value
.graphemes(true)
.nth(self.cursor - 1)
.map(|g| self.grapheme_width(g))
.unwrap_or(0)
}
fn cursor_visual_pos(&self) -> usize {
let mut pos = 0;
if !self.value.is_empty() {
pos += self
.value
.graphemes(true)
.take(self.cursor)
.map(|g| self.grapheme_width(g))
.sum::<usize>();
}
if let Some(ime) = &self.ime_composition {
pos += ime
.graphemes(true)
.map(|g| self.grapheme_width(g))
.sum::<usize>();
}
pos
}
fn effective_scroll(&self, viewport_width: usize) -> usize {
let cursor_visual = self.cursor_visual_pos();
let mut scroll = self.scroll_cells.get();
if cursor_visual < scroll {
scroll = cursor_visual;
}
if cursor_visual >= scroll + viewport_width {
let candidate_scroll = cursor_visual - viewport_width + 1;
let prev_width = self.prev_grapheme_width();
let max_scroll_for_prev = cursor_visual.saturating_sub(prev_width);
if viewport_width > prev_width {
scroll = candidate_scroll.min(max_scroll_for_prev);
} else {
scroll = candidate_scroll;
}
}
scroll = self.snap_scroll_to_grapheme_boundary(scroll, viewport_width);
self.scroll_cells.set(scroll);
scroll
}
fn snap_scroll_to_grapheme_boundary(&self, scroll: usize, viewport_width: usize) -> usize {
let mut pos = 0;
let cursor_visual = self.cursor_visual_pos();
for g in self.value.graphemes(true) {
let w = self.grapheme_width(g);
let next_pos = pos + w;
if pos < scroll && scroll < next_pos {
if cursor_visual <= pos + viewport_width {
return pos;
} else {
return next_pos;
}
}
if next_pos > scroll {
break;
}
pos = next_pos;
}
scroll
}
}
impl Widget for TextInput {
fn render(&self, area: Rect, frame: &mut Frame) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "TextInput",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
if area.width < 1 || area.height < 1 {
return;
}
let deg = frame.buffer.degradation;
let base_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
clear_text_area(frame, area, base_style);
let arena = frame.arena;
let graphemes_heap;
let graphemes: &[&str] = if let Some(a) = arena {
a.alloc_iter(self.value.graphemes(true))
} else {
graphemes_heap = self.value.graphemes(true).collect::<Vec<_>>();
&graphemes_heap
};
let show_placeholder =
self.value.is_empty() && self.ime_composition.is_none() && !self.placeholder.is_empty();
let viewport_width = area.width as usize;
let cursor_visual_pos = self.cursor_visual_pos();
let effective_scroll = self.effective_scroll(viewport_width);
let mut visual_x: usize = 0;
let y = area.y;
if show_placeholder {
let placeholder_style = if deg.apply_styling() {
self.placeholder_style
} else {
Style::default()
};
for g in self.placeholder.graphemes(true) {
let w = self.grapheme_width(g);
if w == 0 {
continue;
}
if visual_x + w <= effective_scroll {
visual_x += w;
continue;
}
if visual_x < effective_scroll {
visual_x += w;
continue;
}
let rel_x = visual_x - effective_scroll;
if rel_x >= viewport_width {
break;
}
if rel_x + w > viewport_width {
break;
}
let mut cell = if g.chars().count() > 1 || w > 1 {
let id = frame.intern_with_width(g, w as u8);
Cell::new(CellContent::from_grapheme(id))
} else if let Some(c) = g.chars().next() {
Cell::from_char(c)
} else {
visual_x += w;
continue;
};
crate::apply_style(&mut cell, placeholder_style);
frame
.buffer
.set(area.x.saturating_add(rel_x as u16), y, cell);
visual_x += w;
}
} else {
let mut display_spans: Vec<(&str, Style, bool)> = Vec::new();
for (gi, g) in graphemes.iter().enumerate() {
if gi == self.cursor
&& let Some(ime) = &self.ime_composition
{
let ime_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
for ig in ime.graphemes(true) {
display_spans.push((ig, ime_style, true));
}
}
let cell_style = if !deg.apply_styling() {
Style::default()
} else if self.is_in_selection(gi) {
self.selection_style
} else {
self.style
};
display_spans.push((g, cell_style, false));
}
if self.cursor == graphemes.len()
&& let Some(ime) = &self.ime_composition
{
let ime_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
for ig in ime.graphemes(true) {
display_spans.push((ig, ime_style, true));
}
}
for (g, cell_style, is_ime) in display_spans {
let w = self.grapheme_width(g);
if w == 0 {
continue;
}
if visual_x + w <= effective_scroll {
visual_x += w;
continue;
}
if visual_x < effective_scroll {
visual_x += w;
continue;
}
let rel_x = visual_x - effective_scroll;
if rel_x >= viewport_width {
break;
}
if rel_x + w > viewport_width {
break;
}
let mut cell = if let Some(mask) = self.mask_char {
Cell::from_char(mask)
} else if g.chars().count() > 1 || w > 1 {
let id = frame.intern_with_width(g, w as u8);
Cell::new(CellContent::from_grapheme(id))
} else {
Cell::from_char(g.chars().next().unwrap_or(' '))
};
crate::apply_style(&mut cell, cell_style);
if is_ime && deg.apply_styling() {
use ftui_render::cell::StyleFlags;
let current_flags = cell.attrs.flags();
cell.attrs = cell.attrs.with_flags(current_flags | StyleFlags::UNDERLINE);
}
frame
.buffer
.set(area.x.saturating_add(rel_x as u16), y, cell);
visual_x += w;
}
}
if self.focused {
let cursor_rel_x = cursor_visual_pos.saturating_sub(effective_scroll);
if cursor_rel_x < viewport_width {
let cursor_screen_x = area.x.saturating_add(cursor_rel_x as u16);
if let Some(cell) = frame.buffer.get_mut(cursor_screen_x, y) {
if !deg.apply_styling() {
use ftui_render::cell::StyleFlags;
let current_flags = cell.attrs.flags();
let new_flags = current_flags ^ StyleFlags::REVERSE;
cell.attrs = cell.attrs.with_flags(new_flags);
} else if self.cursor_style.is_empty() {
use ftui_render::cell::StyleFlags;
let current_flags = cell.attrs.flags();
let new_flags = current_flags ^ StyleFlags::REVERSE;
cell.attrs = cell.attrs.with_flags(new_flags);
} else {
crate::apply_style(cell, self.cursor_style);
}
}
}
frame.set_cursor(Some(self.cursor_position(area)));
frame.set_cursor_visible(true);
}
}
fn is_essential(&self) -> bool {
true
}
}
impl ftui_a11y::Accessible for TextInput {
fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
let id = crate::a11y_node_id(area);
let name = if self.value.is_empty() {
self.placeholder.clone()
} else if self.mask_char.is_some() {
String::from("password field")
} else {
self.value.clone()
};
let state = A11yState {
focused: self.focused,
disabled: self.max_length == Some(0),
..A11yState::default()
};
let mut node = A11yNodeInfo::new(id, A11yRole::TextInput, area).with_state(state);
if !name.is_empty() {
node = node.with_name(name);
}
if self.mask_char.is_some() {
node = node.with_description("password input");
}
vec![node]
}
}
#[derive(Debug, Clone)]
pub struct TextInputSnapshot {
value: String,
cursor: usize,
selection_anchor: Option<usize>,
}
impl UndoSupport for TextInput {
fn undo_widget_id(&self) -> UndoWidgetId {
self.undo_id
}
fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
Box::new(TextInputSnapshot {
value: self.value.clone(),
cursor: self.cursor,
selection_anchor: self.selection_anchor,
})
}
fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
if let Some(snap) = snapshot.downcast_ref::<TextInputSnapshot>() {
self.value = snap.value.clone();
self.cursor = snap.cursor;
self.selection_anchor = snap.selection_anchor;
self.scroll_cells.set(0); true
} else {
false
}
}
}
impl TextInputUndoExt for TextInput {
fn text_value(&self) -> &str {
&self.value
}
fn set_text_value(&mut self, value: &str) {
self.value = value.to_string();
let max = self.grapheme_count();
self.cursor = self.cursor.min(max);
self.selection_anchor = None;
}
fn cursor_position(&self) -> usize {
self.cursor
}
fn set_cursor_position(&mut self, pos: usize) {
let max = self.grapheme_count();
self.cursor = pos.min(max);
}
fn insert_text_at(&mut self, position: usize, text: &str) {
let byte_offset = self.grapheme_byte_offset(position);
self.value.insert_str(byte_offset, text);
let inserted_graphemes = text.graphemes(true).count();
if self.cursor >= position {
self.cursor += inserted_graphemes;
}
}
fn delete_text_range(&mut self, start: usize, end: usize) {
if start >= end {
return;
}
let byte_start = self.grapheme_byte_offset(start);
let byte_end = self.grapheme_byte_offset(end);
self.value.drain(byte_start..byte_end);
let deleted_count = end - start;
if self.cursor > end {
self.cursor -= deleted_count;
} else if self.cursor > start {
self.cursor = start;
}
let gc = self.grapheme_count();
if self.cursor > gc {
self.cursor = gc;
}
}
}
impl TextInput {
#[must_use]
pub fn create_text_edit_command(
&self,
operation: TextEditOperation,
) -> Option<crate::undo_support::WidgetTextEditCmd> {
Some(crate::undo_support::WidgetTextEditCmd::new(
self.undo_id,
operation,
))
}
#[must_use]
pub fn undo_id(&self) -> UndoWidgetId {
self.undo_id
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "tracing")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "tracing")]
use tracing::Subscriber;
#[cfg(feature = "tracing")]
use tracing_subscriber::Layer;
#[cfg(feature = "tracing")]
use tracing_subscriber::layer::{Context, SubscriberExt};
#[allow(dead_code)]
fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
frame
.buffer
.get(x, y)
.copied()
.unwrap_or_else(|| panic!("test cell should exist at ({x},{y})"))
}
fn raw_row_text(frame: &Frame, y: u16, width: u16) -> String {
(0..width)
.map(|x| {
frame
.buffer
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ')
})
.collect()
}
#[cfg(feature = "tracing")]
#[derive(Debug, Default)]
struct InputTraceState {
span_count: usize,
has_cursor_position_field: bool,
cursor_positions: Vec<usize>,
operations: Vec<String>,
}
#[cfg(feature = "tracing")]
struct InputTraceCapture {
state: Arc<Mutex<InputTraceState>>,
}
#[cfg(feature = "tracing")]
impl<S> Layer<S> for InputTraceCapture
where
S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::Id,
_ctx: Context<'_, S>,
) {
if attrs.metadata().name() != "input.edit" {
return;
}
#[derive(Default)]
struct InputEditVisitor {
cursor_position: Option<usize>,
operation: Option<String>,
}
impl tracing::field::Visit for InputEditVisitor {
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
if field.name() == "cursor_position" {
self.cursor_position = usize::try_from(value).ok();
}
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
if field.name() == "cursor_position" {
self.cursor_position = usize::try_from(value).ok();
}
}
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "operation" {
self.operation = Some(format!("{value:?}").trim_matches('"').to_owned());
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "operation" {
self.operation = Some(value.to_owned());
}
}
}
let fields = attrs.metadata().fields();
let mut visitor = InputEditVisitor::default();
attrs.record(&mut visitor);
let mut state = self.state.lock().expect("trace state lock");
state.span_count += 1;
state.has_cursor_position_field |= fields.field("cursor_position").is_some();
if let Some(cursor) = visitor.cursor_position {
state.cursor_positions.push(cursor);
}
if let Some(operation) = visitor.operation {
state.operations.push(operation);
}
}
}
#[allow(dead_code)]
#[test]
fn test_empty_input() {
let input = TextInput::new();
assert!(input.value().is_empty());
assert_eq!(input.cursor(), 0);
assert!(input.selected_text().is_none());
}
#[test]
fn test_with_value() {
let mut input = TextInput::new().with_value("hello");
input.set_focused(true);
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn test_set_value() {
let mut input = TextInput::new().with_value("hello world");
input.cursor = 11;
input.set_value("hi");
assert_eq!(input.value(), "hi");
assert_eq!(input.cursor(), 2);
}
#[test]
fn test_clear() {
let mut input = TextInput::new().with_value("hello");
input.set_focused(true);
input.clear();
assert!(input.value().is_empty());
assert_eq!(input.cursor(), 0);
}
#[test]
fn test_insert_char() {
let mut input = TextInput::new();
input.insert_char('a');
input.insert_char('b');
input.insert_char('c');
assert_eq!(input.value(), "abc");
assert_eq!(input.cursor(), 3);
}
#[test]
fn test_insert_char_mid() {
let mut input = TextInput::new().with_value("ac");
input.cursor = 1;
input.insert_char('b');
assert_eq!(input.value(), "abc");
assert_eq!(input.cursor(), 2);
}
#[test]
fn test_max_length() {
let mut input = TextInput::new().with_max_length(3);
for c in "abcdef".chars() {
input.insert_char(c);
}
assert_eq!(input.value(), "abc");
assert_eq!(input.cursor(), 3);
}
#[test]
fn test_delete_char_back() {
let mut input = TextInput::new().with_value("hello");
input.delete_char_back();
assert_eq!(input.value(), "hell");
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_delete_char_back_at_start() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 0;
input.delete_char_back();
assert_eq!(input.value(), "hello");
}
#[test]
fn test_delete_char_forward() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 0;
input.delete_char_forward();
assert_eq!(input.value(), "ello");
assert_eq!(input.cursor(), 0);
}
#[test]
fn test_delete_char_forward_at_end() {
let mut input = TextInput::new().with_value("hello");
input.delete_char_forward();
assert_eq!(input.value(), "hello");
}
#[test]
fn test_cursor_left_right() {
let mut input = TextInput::new().with_value("hello");
assert_eq!(input.cursor(), 5);
input.move_cursor_left();
assert_eq!(input.cursor(), 4);
input.move_cursor_left();
assert_eq!(input.cursor(), 3);
input.move_cursor_right();
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_cursor_bounds() {
let mut input = TextInput::new().with_value("hi");
input.cursor = 0;
input.move_cursor_left();
assert_eq!(input.cursor(), 0);
input.cursor = 2;
input.move_cursor_right();
assert_eq!(input.cursor(), 2);
}
#[test]
fn test_word_movement_left() {
let mut input = TextInput::new().with_value("hello world test");
input.move_cursor_word_left(false);
assert_eq!(input.cursor(), 12);
input.move_cursor_word_left(false);
assert_eq!(input.cursor(), 6);
input.move_cursor_word_left(false);
assert_eq!(input.cursor(), 0); }
#[test]
fn test_word_movement_right() {
let mut input = TextInput::new().with_value("hello world test");
input.cursor = 0;
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 6);
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 12);
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 16); }
#[test]
fn test_word_movement_skips_punctuation() {
let mut input = TextInput::new().with_value("hello, world");
input.cursor = 0;
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 5);
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 7);
input.move_cursor_word_right(false);
assert_eq!(input.cursor(), 12);
input.move_cursor_word_left(false);
assert_eq!(input.cursor(), 7); }
#[test]
fn test_delete_word_back() {
let mut input = TextInput::new().with_value("hello world");
input.delete_word_back();
assert_eq!(input.value(), "hello ");
input.delete_word_back();
assert_eq!(input.value(), ""); }
#[test]
fn test_delete_word_forward() {
let mut input = TextInput::new().with_value("hello world");
input.cursor = 0;
input.delete_word_forward();
assert_eq!(input.value(), "world");
input.delete_word_forward();
assert_eq!(input.value(), ""); }
#[test]
fn test_select_all() {
let mut input = TextInput::new().with_value("hello");
input.select_all();
assert_eq!(input.selected_text(), Some("hello"));
}
#[test]
fn test_delete_selection() {
let mut input = TextInput::new().with_value("hello world");
input.selection_anchor = Some(0);
input.cursor = 5;
input.delete_selection();
assert_eq!(input.value(), " world");
assert_eq!(input.cursor(), 0);
}
#[test]
fn test_insert_replaces_selection() {
let mut input = TextInput::new().with_value("hello");
input.select_all();
input.delete_selection();
input.insert_char('x');
assert_eq!(input.value(), "x");
}
#[test]
fn test_unicode_grapheme_handling() {
let mut input = TextInput::new();
input.set_value("café");
assert_eq!(input.grapheme_count(), 4);
input.cursor = 4;
input.delete_char_back();
assert_eq!(input.value(), "caf");
}
#[test]
fn test_multi_codepoint_grapheme_cursor_movement() {
let mut input = TextInput::new().with_value("a👩💻b");
assert_eq!(input.grapheme_count(), 3);
assert_eq!(input.cursor(), 3);
input.move_cursor_left();
assert_eq!(input.cursor(), 2);
input.move_cursor_left();
assert_eq!(input.cursor(), 1);
input.move_cursor_left();
assert_eq!(input.cursor(), 0);
input.move_cursor_right();
assert_eq!(input.cursor(), 1);
input.move_cursor_right();
assert_eq!(input.cursor(), 2);
input.move_cursor_right();
assert_eq!(input.cursor(), 3);
}
#[test]
fn test_delete_back_multi_codepoint_grapheme() {
let mut input = TextInput::new().with_value("a👩💻b");
input.cursor = 2; input.delete_char_back();
assert_eq!(input.value(), "ab");
assert_eq!(input.cursor(), 1);
assert_eq!(input.grapheme_count(), 2);
}
#[test]
fn test_ime_composition_start_update_commit() {
let mut input = TextInput::new().with_value("ab");
input.cursor = 1;
input.ime_start_composition();
assert_eq!(input.ime_composition(), Some(""));
input.ime_update_composition("漢");
assert_eq!(input.ime_composition(), Some("漢"));
assert!(input.ime_commit_composition());
assert_eq!(input.ime_composition(), None);
assert_eq!(input.value(), "a漢b");
assert_eq!(input.cursor(), 2);
}
#[test]
fn test_ime_composition_cancel_keeps_value() {
let mut input = TextInput::new().with_value("hello");
input.ime_start_composition();
input.ime_update_composition("👩💻");
assert_eq!(input.ime_composition(), Some("👩💻"));
assert!(input.ime_cancel_composition());
assert_eq!(input.ime_composition(), None);
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn test_ime_commit_without_session_is_noop() {
let mut input = TextInput::new().with_value("abc");
assert!(!input.ime_commit_composition());
assert_eq!(input.value(), "abc");
assert_eq!(input.cursor(), 3);
}
#[test]
fn test_handle_event_ime_update_and_commit() {
let mut input = TextInput::new().with_value("ab");
input.cursor = 1;
assert!(input.handle_event(&Event::Ime(ImeEvent::start())));
assert!(input.handle_event(&Event::Ime(ImeEvent::update("漢"))));
assert_eq!(input.ime_composition(), Some("漢"));
assert!(input.handle_event(&Event::Ime(ImeEvent::commit("漢"))));
assert_eq!(input.ime_composition(), None);
assert_eq!(input.value(), "a漢b");
assert_eq!(input.cursor(), 2);
}
#[test]
fn test_handle_event_ime_cancel() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 5;
assert!(input.handle_event(&Event::Ime(ImeEvent::start())));
assert!(input.handle_event(&Event::Ime(ImeEvent::update("👩💻"))));
assert!(input.handle_event(&Event::Ime(ImeEvent::cancel())));
assert_eq!(input.ime_composition(), None);
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn test_flag_emoji_grapheme_delete_and_cursor() {
let mut input = TextInput::new().with_value("a🇺🇸b");
assert_eq!(input.grapheme_count(), 3);
input.cursor = 2;
input.delete_char_back();
assert_eq!(input.value(), "ab");
assert_eq!(input.cursor(), 1);
}
#[test]
fn test_combining_grapheme_delete_and_cursor() {
let mut input = TextInput::new().with_value("a\u{0301}b");
assert_eq!(input.grapheme_count(), 2);
input.cursor = 1;
input.delete_char_back();
assert_eq!(input.value(), "b");
assert_eq!(input.cursor(), 0);
}
#[test]
fn test_bidi_logical_cursor_movement_over_graphemes() {
let mut input = TextInput::new().with_value("AאבB");
assert_eq!(input.grapheme_count(), 4);
input.move_cursor_left();
assert_eq!(input.cursor(), 3);
input.move_cursor_left();
assert_eq!(input.cursor(), 2);
input.move_cursor_left();
assert_eq!(input.cursor(), 1);
input.move_cursor_left();
assert_eq!(input.cursor(), 0);
input.move_cursor_right();
assert_eq!(input.cursor(), 1);
input.move_cursor_right();
assert_eq!(input.cursor(), 2);
input.move_cursor_right();
assert_eq!(input.cursor(), 3);
input.move_cursor_right();
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_handle_event_char() {
let mut input = TextInput::new();
let event = Event::Key(KeyEvent::new(KeyCode::Char('a')));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "a");
}
#[test]
fn test_handle_event_backspace() {
let mut input = TextInput::new().with_value("ab");
let event = Event::Key(KeyEvent::new(KeyCode::Backspace));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "a");
}
#[test]
fn test_handle_event_ctrl_a() {
let mut input = TextInput::new().with_value("hello");
let event = Event::Key(KeyEvent::new(KeyCode::Char('a')).with_modifiers(Modifiers::CTRL));
assert!(input.handle_event(&event));
assert_eq!(input.selected_text(), Some("hello"));
}
#[test]
fn test_handle_event_ctrl_backspace() {
let mut input = TextInput::new().with_value("hello world");
let event = Event::Key(KeyEvent::new(KeyCode::Backspace).with_modifiers(Modifiers::CTRL));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "hello ");
}
#[test]
fn test_handle_event_home_end() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 3;
let home = Event::Key(KeyEvent::new(KeyCode::Home));
assert!(input.handle_event(&home));
assert_eq!(input.cursor(), 0);
let end = Event::Key(KeyEvent::new(KeyCode::End));
assert!(input.handle_event(&end));
assert_eq!(input.cursor(), 5);
}
#[test]
fn test_shift_left_creates_selection() {
let mut input = TextInput::new().with_value("hello");
let event = Event::Key(KeyEvent::new(KeyCode::Left).with_modifiers(Modifiers::SHIFT));
assert!(input.handle_event(&event));
assert_eq!(input.cursor(), 4);
assert_eq!(input.selection_anchor, Some(5));
assert_eq!(input.selected_text(), Some("o"));
}
#[test]
fn test_cursor_position() {
let input = TextInput::new().with_value("hello");
let area = Rect::new(10, 5, 20, 1);
let (x, y) = input.cursor_position(area);
assert_eq!(x, 15);
assert_eq!(y, 5);
}
#[test]
fn test_cursor_position_empty() {
let input = TextInput::new();
let area = Rect::new(0, 0, 80, 1);
let (x, y) = input.cursor_position(area);
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn test_password_mask() {
let input = TextInput::new().with_mask('*').with_value("secret");
assert_eq!(input.value(), "secret");
assert_eq!(input.cursor_visual_pos(), 6);
}
#[test]
fn test_render_basic() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let input = TextInput::new().with_value("hi");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
input.render(area, &mut frame);
let cell_h = cell_at(&frame, 0, 0);
assert_eq!(cell_h.content.as_char(), Some('h'));
let cell_i = cell_at(&frame, 1, 0);
assert_eq!(cell_i.content.as_char(), Some('i'));
}
#[test]
fn test_render_sets_cursor_when_focused() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let input = TextInput::new().with_value("hi").with_focused(true);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
input.render(area, &mut frame);
assert_eq!(frame.cursor_position, Some((2, 0)));
assert!(frame.cursor_visible);
}
#[test]
fn test_render_does_not_set_cursor_when_unfocused() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let input = TextInput::new().with_value("hi");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
input.render(area, &mut frame);
assert!(frame.cursor_position.is_none());
}
#[test]
fn test_render_grapheme_uses_pool() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let grapheme = "👩💻";
let input = TextInput::new().with_value(grapheme);
let area = Rect::new(0, 0, 6, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
input.render(area, &mut frame);
let cell = cell_at(&frame, 0, 0);
assert!(cell.content.is_grapheme());
let width = grapheme_width(grapheme);
if width > 1 {
assert!(cell_at(&frame, 1, 0).is_continuation());
}
}
#[test]
fn test_render_shorter_value_clears_stale_suffix_and_owned_rows() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let area = Rect::new(0, 0, 8, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(8, 2, &mut pool);
TextInput::new()
.with_value("abcdef")
.render(area, &mut frame);
TextInput::new().with_value("xy").render(area, &mut frame);
assert_eq!(raw_row_text(&frame, 0, 8), "xy ");
assert_eq!(raw_row_text(&frame, 1, 8), " ");
}
#[test]
fn test_left_collapses_selection() {
let mut input = TextInput::new().with_value("hello");
input.selection_anchor = Some(1);
input.cursor = 4;
input.move_cursor_left();
assert_eq!(input.cursor(), 1);
assert!(input.selection_anchor.is_none());
}
#[test]
fn test_right_collapses_selection() {
let mut input = TextInput::new().with_value("hello");
input.selection_anchor = Some(1);
input.cursor = 4;
input.move_cursor_right();
assert_eq!(input.cursor(), 4);
assert!(input.selection_anchor.is_none());
}
#[test]
fn test_render_sets_frame_cursor() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let input = TextInput::new().with_value("hello").with_focused(true);
let area = Rect::new(5, 3, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 10, &mut pool);
input.render(area, &mut frame);
assert_eq!(frame.cursor_position, Some((10, 3)));
}
#[test]
fn test_render_cursor_mid_text() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let mut input = TextInput::new().with_value("hello").with_focused(true);
input.cursor = 2; let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
input.render(area, &mut frame);
assert_eq!(frame.cursor_position, Some((2, 0)));
}
#[test]
fn test_undo_widget_id_is_stable() {
let input = TextInput::new();
let id1 = input.undo_id();
let id2 = input.undo_id();
assert_eq!(id1, id2);
}
#[test]
fn test_undo_widget_id_unique_per_instance() {
let input1 = TextInput::new();
let input2 = TextInput::new();
assert_ne!(input1.undo_id(), input2.undo_id());
}
#[test]
fn test_snapshot_and_restore() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 3;
input.selection_anchor = Some(1);
let snapshot = input.create_snapshot();
input.set_value("world");
input.cursor = 5;
input.selection_anchor = None;
assert_eq!(input.value(), "world");
assert_eq!(input.cursor(), 5);
assert!(input.restore_snapshot(snapshot.as_ref()));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 3);
assert_eq!(input.selection_anchor, Some(1));
}
#[test]
fn test_text_input_undo_ext_insert() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 2;
input.insert_text_at(2, " world");
assert_eq!(input.value(), "he worldllo");
assert_eq!(input.cursor(), 8); }
#[test]
fn test_text_input_undo_ext_delete() {
let mut input = TextInput::new().with_value("hello world");
input.cursor = 8;
input.delete_text_range(5, 11); assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5); }
#[test]
fn test_create_text_edit_command() {
let input = TextInput::new().with_value("hello");
let cmd = input.create_text_edit_command(TextEditOperation::Insert {
position: 0,
text: "hi".to_string(),
});
assert!(cmd.is_some());
let cmd = cmd.expect("test command should exist");
assert_eq!(cmd.widget_id(), input.undo_id());
assert_eq!(cmd.description(), "Insert text");
}
#[test]
fn test_paste_bulk_insert() {
let mut input = TextInput::new().with_value("hello");
input.cursor = 5;
let event = Event::Paste(ftui_core::event::PasteEvent::bracketed(" world"));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "hello world");
assert_eq!(input.cursor(), 11);
}
#[test]
fn test_paste_multi_grapheme_sequence() {
let mut input = TextInput::new().with_value("hi");
input.cursor = 2;
let event = Event::Paste(ftui_core::event::PasteEvent::new("👩💻🔥", false));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "hi👩💻🔥");
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_paste_max_length() {
let mut input = TextInput::new().with_value("abc").with_max_length(5);
input.cursor = 3;
let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("def"));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "abcde");
assert_eq!(input.cursor(), 5);
}
#[test]
fn test_paste_combining_merge() {
let mut input = TextInput::new().with_value("e");
input.cursor = 1;
let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "e\u{0301}");
assert_eq!(input.grapheme_count(), 1);
assert_eq!(input.cursor(), 1);
}
#[test]
fn test_paste_combining_merge_mid_string() {
let mut input = TextInput::new().with_value("ab");
input.cursor = 1; let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
assert!(input.handle_event(&event));
assert_eq!(input.value(), "a\u{0301}b");
assert_eq!(input.grapheme_count(), 2);
assert_eq!(input.cursor(), 1);
}
#[test]
fn test_wide_char_scroll_visibility() {
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
let wide_char = "\u{3000}"; let mut input = TextInput::new().with_value(wide_char).with_focused(true);
input.cursor = 1;
let area = Rect::new(0, 0, 2, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(2, 1, &mut pool);
input.render(area, &mut frame);
let cell = cell_at(&frame, 0, 0);
assert!(!cell.is_empty(), "Wide char should be visible");
}
#[test]
fn test_wide_char_scroll_snapping() {
let _input = TextInput::new().with_value("a\u{3000}b");
let wide_char = "\u{3000}";
let text = format!("a{wide_char}b");
let mut input = TextInput::new().with_value(&text);
input.cursor = 3; let _area = Rect::new(0, 0, 2, 1);
}
#[cfg(feature = "tracing")]
#[test]
fn tracing_input_edit_span_tracks_cursor_positions() {
let state = Arc::new(Mutex::new(InputTraceState::default()));
let _trace_test_guard = crate::tracing_test_support::acquire();
let subscriber = tracing_subscriber::registry().with(InputTraceCapture {
state: Arc::clone(&state),
});
let _guard = tracing::subscriber::set_default(subscriber);
tracing::callsite::rebuild_interest_cache();
let mut input = TextInput::new().with_value("ab");
tracing::callsite::rebuild_interest_cache();
assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('c')))));
tracing::callsite::rebuild_interest_cache();
assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Left))));
tracing::callsite::rebuild_interest_cache();
assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Backspace))));
tracing::callsite::rebuild_interest_cache();
let snapshot = state.lock().expect("trace state lock");
assert!(
snapshot.span_count >= 3,
"expected at least 3 input.edit spans, got {}",
snapshot.span_count
);
assert!(
snapshot.has_cursor_position_field,
"input.edit span missing cursor_position field"
);
assert_eq!(
snapshot.cursor_positions,
vec![3, 2, 1],
"expected cursor positions after insert/left/backspace"
);
assert!(
snapshot.operations.starts_with(&[
"insert_char".to_string(),
"move_left".to_string(),
"delete_back".to_string()
]),
"unexpected operations: {:?}",
snapshot.operations
);
}
}
#[cfg(test)]
mod scroll_edge_tests {
use super::*;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
#[test]
fn test_scroll_snap_left_cursor_visibility() {
let mut input = TextInput::new().with_value("ABC");
input.cursor = 1;
let area = Rect::new(0, 0, 1, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
input.render(area, &mut frame);
input.move_cursor_left();
input.render(area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('A'));
}
#[test]
fn test_max_length_replacement_failure() {
let mut input = TextInput::new().with_value("abc").with_max_length(3);
input.selection_anchor = Some(1); input.cursor = 2;
let event = Event::Paste(ftui_core::event::PasteEvent::new("de", false));
input.handle_event(&event);
assert_eq!(input.value(), "abc");
assert_eq!(input.cursor(), 2);
assert_eq!(input.selection_anchor, Some(1));
}
}