use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TextInput {
chars: Vec<String>,
cursor: usize,
sel_anchor: Option<usize>,
scroll: usize,
max_len: usize,
max_bytes: usize,
}
#[derive(Debug, Clone)]
pub struct View {
pub text: String,
pub cursor_col: u16,
pub sel_cols: Option<(u16, u16)>,
}
#[derive(Debug, Clone)]
pub struct WrappedLine {
pub text: String,
pub sel_cols: Option<(u16, u16)>,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone)]
pub struct WrappedView {
pub lines: Vec<WrappedLine>,
pub cursor_row: u16,
pub cursor_col: u16,
}
pub fn wrap_breaks(chars: &[String], width: usize) -> Vec<(usize, usize)> {
if chars.is_empty() {
return vec![(0, 0)];
}
let width = width.max(1);
let mut lines = Vec::new();
let mut start = 0usize;
let mut col = 0usize;
let mut last_ws: Option<usize> = None;
let mut i = 0usize;
while i < chars.len() {
let w = chars[i].width();
if w > width {
if i > start {
lines.push((start, i));
}
lines.push((i, i + 1));
i += 1;
start = i;
col = 0;
last_ws = None;
continue;
}
if col + w > width && i > start {
let end = last_ws
.filter(|&ws| ws >= start)
.map(|ws| ws + 1)
.unwrap_or(i);
let end = end.max(start + 1);
lines.push((start, end));
start = end;
i = start;
col = 0;
last_ws = None;
continue;
}
if is_whitespace(&chars[i]) {
last_ws = Some(i);
}
col += w;
i += 1;
}
if start <= chars.len() {
lines.push((start, chars.len()));
}
if lines.is_empty() {
lines.push((0, 0));
}
lines
}
fn is_whitespace(grapheme: &str) -> bool {
!grapheme.is_empty() && grapheme.chars().all(char::is_whitespace)
}
impl TextInput {
pub fn new(initial: &str, max_len: usize) -> Self {
let max_bytes = crate::model::text_byte_limit(max_len);
let chars = bounded_graphemes(initial, max_len, max_bytes);
let cursor = chars.len();
Self {
chars,
cursor,
sel_anchor: None,
scroll: 0,
max_len,
max_bytes,
}
}
pub fn value(&self) -> String {
self.chars.concat()
}
pub fn is_empty(&self) -> bool {
self.chars.is_empty()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn len(&self) -> usize {
self.chars.len()
}
pub fn slice(&self, start: usize, end: usize) -> String {
let start = start.min(self.chars.len());
let end = end.max(start).min(self.chars.len());
self.chars[start..end].concat()
}
pub fn clear_selection(&mut self) {
self.sel_anchor = None;
}
pub fn has_selection(&self) -> bool {
self.selection_range().is_some()
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
let a = self.sel_anchor?;
let (lo, hi) = if a <= self.cursor {
(a, self.cursor)
} else {
(self.cursor, a)
};
(lo < hi).then_some((lo, hi))
}
pub fn selected_text(&self) -> Option<String> {
let (lo, hi) = self.selection_range()?;
Some(self.chars[lo..hi].concat())
}
pub fn delete_selection(&mut self) -> bool {
let Some((lo, hi)) = self.selection_range() else {
return false;
};
self.replace_range(lo, hi, "");
true
}
pub fn select_word(&mut self) {
if self.chars.is_empty() {
self.sel_anchor = None;
return;
}
let n = self.chars.len();
let i = self.cursor.min(n);
if i < n && !is_whitespace(&self.chars[i]) {
let mut start = i;
while start > 0 && !is_whitespace(&self.chars[start - 1]) {
start -= 1;
}
let mut end = i;
while end < n && !is_whitespace(&self.chars[end]) {
end += 1;
}
self.sel_anchor = Some(start);
self.cursor = end;
return;
}
if i > 0 && !is_whitespace(&self.chars[i - 1]) {
let mut start = i;
while start > 0 && !is_whitespace(&self.chars[start - 1]) {
start -= 1;
}
self.sel_anchor = Some(start);
self.cursor = i;
return;
}
let mut start = i;
while start < n && is_whitespace(&self.chars[start]) {
start += 1;
}
if start < n {
let mut end = start;
while end < n && !is_whitespace(&self.chars[end]) {
end += 1;
}
self.sel_anchor = Some(start);
self.cursor = end;
return;
}
if i > 0 {
let mut end = i;
while end > 0 && is_whitespace(&self.chars[end - 1]) {
end -= 1;
}
let mut start = end;
while start > 0 && !is_whitespace(&self.chars[start - 1]) {
start -= 1;
}
if start < end {
self.sel_anchor = Some(start);
self.cursor = end;
return;
}
}
self.sel_anchor = None;
}
pub fn set_cursor(&mut self, cursor: usize) {
self.clear_selection();
self.cursor = cursor.min(self.chars.len());
}
pub fn set_cursor_from_col(&mut self, col: usize) {
self.clear_selection();
let mut used = 0;
let mut cursor = self.scroll;
for c in &self.chars[self.scroll.min(self.chars.len())..] {
let w = c.width();
if used + w > col {
break;
}
used += w;
cursor += 1;
}
self.cursor = cursor.min(self.chars.len());
}
pub fn at_start(&self) -> bool {
self.cursor == 0
}
pub fn at_end(&self) -> bool {
self.cursor == self.chars.len()
}
pub fn split_off_at_cursor(&mut self) -> Self {
self.clear_selection();
let tail: Vec<String> = self.chars.split_off(self.cursor);
Self {
chars: tail,
cursor: 0,
sel_anchor: None,
scroll: 0,
max_len: self.max_len,
max_bytes: self.max_bytes,
}
}
pub fn append(&mut self, other: &Self) -> bool {
let left = self.value();
let cursor = left.graphemes(true).count();
let right = other.value();
if left.len().saturating_add(right.len()) > self.max_bytes {
return false;
}
let joined = left + &right;
let chars: Vec<String> = joined.graphemes(true).map(str::to_string).collect();
if chars.len() > self.max_len {
return false;
}
self.clear_selection();
self.chars = chars;
self.cursor = cursor.min(self.chars.len());
self.scroll = self.scroll.min(self.cursor);
true
}
fn replace_range(&mut self, lo: usize, hi: usize, replacement: &str) {
let lo = lo.min(self.chars.len());
let hi = hi.max(lo).min(self.chars.len());
let prefix = self.chars[..lo].concat();
let suffix = self.chars[hi..].concat();
let before_cursor = format!("{prefix}{replacement}");
let cursor = before_cursor.graphemes(true).count();
let joined = before_cursor + &suffix;
self.chars = joined.graphemes(true).map(str::to_string).collect();
self.cursor = cursor.min(self.chars.len());
self.sel_anchor = None;
self.scroll = self.scroll.min(self.cursor);
}
pub fn insert(&mut self, c: char) {
self.insert_str(&c.to_string());
}
pub fn insert_str(&mut self, text: &str) {
let (lo, hi) = self.selection_range().unwrap_or((self.cursor, self.cursor));
let prefix = self.chars[..lo].concat();
let suffix = self.chars[hi..].concat();
let available = self
.max_bytes
.saturating_sub(prefix.len().saturating_add(suffix.len()));
let mut flattened = String::with_capacity(text.len().min(available));
for character in text.chars().filter_map(|character| match character {
'\t' | '\n' | '\r' => Some(' '),
character if character.is_control() => None,
_ => Some(character),
}) {
if flattened.len().saturating_add(character.len_utf8()) > available {
break;
}
flattened.push(character);
}
if flattened.is_empty() {
return;
}
let mut accepted = String::new();
for grapheme in flattened.graphemes(true) {
let candidate = format!("{prefix}{accepted}{grapheme}{suffix}");
if candidate.len() > self.max_bytes || candidate.graphemes(true).count() > self.max_len
{
break;
}
accepted.push_str(grapheme);
}
if !accepted.is_empty() {
self.replace_range(lo, hi, &accepted);
}
}
pub fn backspace(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor > 0 {
self.replace_range(self.cursor - 1, self.cursor, "");
}
}
pub fn delete(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor < self.chars.len() {
self.replace_range(self.cursor, self.cursor + 1, "");
}
}
pub fn delete_to_start(&mut self) {
if self.delete_selection() {
return;
}
self.replace_range(0, self.cursor, "");
}
pub fn delete_to_end(&mut self) {
if self.delete_selection() {
return;
}
self.replace_range(self.cursor, self.chars.len(), "");
}
pub fn delete_word_left(&mut self) {
if self.delete_selection() {
return;
}
let target = self.word_left_index();
self.replace_range(target, self.cursor, "");
}
fn move_to(&mut self, pos: usize, extend: bool) {
if extend {
if self.sel_anchor.is_none() {
self.sel_anchor = Some(self.cursor);
}
} else {
self.sel_anchor = None;
}
self.cursor = pos.min(self.chars.len());
}
pub fn left(&mut self) {
self.move_to(self.cursor.saturating_sub(1), false);
}
pub fn right(&mut self) {
self.move_to((self.cursor + 1).min(self.chars.len()), false);
}
pub fn select_left(&mut self) {
self.move_to(self.cursor.saturating_sub(1), true);
}
pub fn select_right(&mut self) {
self.move_to((self.cursor + 1).min(self.chars.len()), true);
}
pub fn word_left(&mut self) {
self.move_to(self.word_left_index(), false);
}
pub fn word_right(&mut self) {
self.move_to(self.word_right_index(), false);
}
pub fn select_word_left(&mut self) {
self.move_to(self.word_left_index(), true);
}
pub fn select_word_right(&mut self) {
self.move_to(self.word_right_index(), true);
}
pub fn home(&mut self) {
self.move_to(0, false);
}
pub fn end(&mut self) {
self.move_to(self.chars.len(), false);
}
pub fn select_home(&mut self) {
self.move_to(0, true);
}
pub fn select_end(&mut self) {
self.move_to(self.chars.len(), true);
}
pub fn word_left_index(&self) -> usize {
let mut i = self.cursor;
while i > 0 && is_whitespace(&self.chars[i - 1]) {
i -= 1;
}
while i > 0 && !is_whitespace(&self.chars[i - 1]) {
i -= 1;
}
i
}
pub fn word_right_index(&self) -> usize {
let mut i = self.cursor;
let n = self.chars.len();
while i < n && is_whitespace(&self.chars[i]) {
i += 1;
}
while i < n && !is_whitespace(&self.chars[i]) {
i += 1;
}
i
}
pub fn wrap_breaks(&self, width: usize) -> Vec<(usize, usize)> {
wrap_breaks(&self.chars, width)
}
pub fn wrap_height(&self, width: usize) -> usize {
self.wrap_breaks(width).len().max(1)
}
pub fn wrap_cursor_from_breaks(&self, breaks: &[(usize, usize)]) -> (usize, u16) {
for (row, &(start, end)) in breaks.iter().enumerate() {
if self.cursor < end || (self.cursor == end && row + 1 == breaks.len()) {
let col: usize = self.chars[start..self.cursor.min(end)]
.iter()
.map(|c| c.width())
.sum();
if self.cursor == end && row + 1 < breaks.len() && end < self.chars.len() {
return (row + 1, 0);
}
return (row, col as u16);
}
}
let last = breaks.len().saturating_sub(1);
(last, 0)
}
pub fn wrap_cursor(&self, width: usize) -> (usize, u16) {
self.wrap_cursor_from_breaks(&self.wrap_breaks(width))
}
pub fn set_cursor_from_wrap(&mut self, width: usize, row: usize, col: usize) {
self.clear_selection();
let breaks = self.wrap_breaks(width);
if breaks.is_empty() {
self.cursor = 0;
return;
}
let row = row.min(breaks.len() - 1);
let (start, end) = breaks[row];
let mut used = 0usize;
let mut cursor = start;
for c in &self.chars[start..end] {
let w = c.width();
if used + w > col {
break;
}
used += w;
cursor += 1;
}
self.cursor = cursor.min(self.chars.len());
}
pub fn wrap_up(&mut self, width: usize, prefer_col: u16) -> bool {
let (row, col) = self.wrap_cursor(width);
let prefer = if prefer_col == u16::MAX {
col
} else {
prefer_col
};
if row == 0 {
return false;
}
self.set_cursor_from_wrap(width, row - 1, prefer as usize);
true
}
pub fn wrap_down(&mut self, width: usize, prefer_col: u16) -> bool {
let (row, col) = self.wrap_cursor(width);
let prefer = if prefer_col == u16::MAX {
col
} else {
prefer_col
};
let height = self.wrap_height(width);
if row + 1 >= height {
return false;
}
self.set_cursor_from_wrap(width, row + 1, prefer as usize);
true
}
pub fn wrapped(&self, width: usize) -> WrappedView {
self.wrapped_with_sel(width, self.selection_range())
}
pub fn wrapped_with_sel(&self, width: usize, sel: Option<(usize, usize)>) -> WrappedView {
self.wrapped_from_breaks(&self.wrap_breaks(width), sel)
}
pub fn wrapped_from_breaks(
&self,
breaks: &[(usize, usize)],
sel: Option<(usize, usize)>,
) -> WrappedView {
let (cursor_row, cursor_col) = self.wrap_cursor_from_breaks(breaks);
let lines = breaks
.iter()
.map(|&(start, end)| {
let text = self.chars[start..end].concat();
let sel_cols = sel.and_then(|(lo, hi)| {
let vis_lo = lo.max(start);
let vis_hi = hi.min(end);
if vis_lo >= vis_hi {
return None;
}
let col = |idx: usize| -> u16 {
self.chars[start..idx]
.iter()
.map(|c| c.width())
.sum::<usize>() as u16
};
Some((col(vis_lo), col(vis_hi)))
});
WrappedLine {
text,
sel_cols,
start,
end,
}
})
.collect();
WrappedView {
lines,
cursor_row: cursor_row as u16,
cursor_col,
}
}
pub fn place_cursor(&mut self, cursor: usize) {
self.cursor = cursor.min(self.chars.len());
self.sel_anchor = None;
}
pub fn visible(&mut self, width: usize) -> View {
if width == 0 {
return View {
text: String::new(),
cursor_col: 0,
sel_cols: None,
};
}
if self.cursor < self.scroll {
self.scroll = self.cursor;
}
loop {
let used: usize = self.chars[self.scroll..self.cursor]
.iter()
.map(|c| c.width())
.sum();
if used <= width || self.scroll >= self.cursor {
break;
}
self.scroll += 1;
}
let mut text = String::new();
let mut used = 0usize;
let mut end_idx = self.scroll;
for c in &self.chars[self.scroll..] {
let w = c.width();
if used + w > width {
break;
}
used += w;
text.push_str(c);
end_idx += 1;
}
let cursor_col: usize = self.chars[self.scroll..self.cursor]
.iter()
.map(|c| c.width())
.sum();
let sel_cols = self.selection_range().and_then(|(lo, hi)| {
let vis_lo = lo.max(self.scroll);
let vis_hi = hi.min(end_idx);
if vis_lo >= vis_hi {
return None;
}
let col = |idx: usize| -> u16 {
self.chars[self.scroll..idx]
.iter()
.map(|c| c.width())
.sum::<usize>() as u16
};
Some((col(vis_lo), col(vis_hi)))
});
View {
text,
cursor_col: cursor_col.min(width) as u16,
sel_cols,
}
}
}
fn bounded_graphemes(value: &str, max_len: usize, max_bytes: usize) -> Vec<String> {
let mut result = Vec::with_capacity(value.len().min(max_len));
let mut bytes = 0usize;
for grapheme in value.graphemes(true).take(max_len) {
if bytes.saturating_add(grapheme.len()) > max_bytes {
break;
}
bytes += grapheme.len();
result.push(grapheme.to_string());
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn input(text: &str) -> TextInput {
TextInput::new(text, 256)
}
#[test]
fn edits_at_the_cursor() {
let mut i = input("hello");
i.left();
i.insert('!');
assert_eq!(i.value(), "hell!o");
i.backspace();
assert_eq!(i.value(), "hello");
i.delete();
assert_eq!(i.value(), "hell");
}
#[test]
fn moves_and_deletes_by_word() {
let mut i = input("one two three");
i.word_left();
assert_eq!(i.cursor, 8);
i.word_left();
assert_eq!(i.cursor, 4);
i.delete_word_left();
assert_eq!(i.value(), "two three");
i.end();
i.delete_to_start();
assert!(i.is_empty());
}
#[test]
fn honours_the_length_limit() {
let mut i = TextInput::new("ab", 2);
i.insert('c');
assert_eq!(i.value(), "ab");
}
#[test]
fn combining_sequences_cannot_bypass_the_byte_limit() {
let mut typed = TextInput::new("", 1);
typed.insert('e');
for _ in 0..1_000 {
typed.insert('\u{301}');
}
assert_eq!(typed.len(), 1);
assert!(typed.value().len() <= crate::model::text_byte_limit(1));
let pasted = format!("e{}", "\u{301}".repeat(1_000));
let pasted = TextInput::new(&pasted, 1);
assert!(pasted.value().len() <= crate::model::text_byte_limit(1));
}
#[test]
fn appending_inputs_preserves_the_same_limits_as_typing() {
let mut input = TextInput::new("a", 1);
assert!(!input.append(&TextInput::new("b", 1)));
assert_eq!(input.value(), "a");
}
#[test]
fn scrolls_to_keep_the_cursor_in_view() {
let mut i = input("abcdefghij");
let view = i.visible(4);
assert_eq!(view.text, "ghij");
assert_eq!(view.cursor_col, 4);
i.home();
let view = i.visible(4);
assert_eq!(view.text, "abcd");
assert_eq!(view.cursor_col, 0);
}
#[test]
fn measures_wide_characters_in_columns() {
let mut i = input("买菜");
let view = i.visible(4);
assert_eq!(view.text, "买菜");
assert_eq!(view.cursor_col, 4);
}
#[test]
fn cursor_and_deletion_treat_combining_sequences_as_one_grapheme() {
let mut i = input("e\u{301}x");
assert_eq!(i.len(), 2);
i.left();
i.backspace();
assert_eq!(i.value(), "x");
assert_eq!(i.cursor(), 0);
}
#[test]
fn cursor_and_selection_do_not_split_zwj_emoji() {
let family = "👨👩👧👦";
let mut i = input(&format!("{family}!"));
assert_eq!(i.len(), 2);
i.home();
i.select_right();
assert_eq!(i.selected_text().as_deref(), Some(family));
i.delete_selection();
assert_eq!(i.value(), "!");
}
#[test]
fn paste_keeps_graphemes_but_strips_terminal_controls() {
let family = "👨👩👧👦";
let mut i = TextInput::new("", 64);
i.insert_str(&format!("e\u{301}\t{family}\u{1b}\u{7f}\u{85}!"));
assert_eq!(i.value(), format!("e\u{301} {family}!"));
assert_eq!(i.len(), 4);
assert!(!i.value().chars().any(char::is_control));
}
#[test]
fn separately_typed_combining_marks_merge_with_the_previous_grapheme() {
let mut i = TextInput::new("", 1);
i.insert('e');
i.insert('\u{301}');
assert_eq!(i.value(), "e\u{301}");
assert_eq!(i.len(), 1);
}
#[test]
fn select_word_picks_the_word_under_the_cursor() {
let mut i = input("one two three");
i.home();
i.word_right(); i.right(); i.select_word();
assert_eq!(i.selected_text().as_deref(), Some("two"));
assert_eq!(i.selection_range(), Some((4, 7)));
}
#[test]
fn shift_arrows_extend_the_selection() {
let mut i = input("hello");
i.home();
i.select_right();
i.select_right();
assert_eq!(i.selected_text().as_deref(), Some("he"));
i.delete_selection();
assert_eq!(i.value(), "llo");
}
#[test]
fn typing_replaces_the_selection() {
let mut i = input("hello");
i.home();
i.select_word();
i.insert('x');
assert_eq!(i.value(), "x");
}
#[test]
fn wraps_on_spaces_then_hard_breaks() {
let s: Vec<String> = "one two three"
.graphemes(true)
.map(str::to_string)
.collect();
let breaks = wrap_breaks(&s, 8);
let parts: Vec<String> = breaks.iter().map(|&(a, b)| s[a..b].concat()).collect();
assert!(parts.len() >= 2);
assert!(parts[0].starts_with("one"));
assert!(parts.iter().any(|p| p.contains("three")));
}
#[test]
fn wrap_ranges_preserve_every_space_and_accept_every_caret_position() {
let mut input = input("word next");
let breaks = input.wrap_breaks(5);
assert_eq!(breaks.first().map(|range| range.0), Some(0));
assert_eq!(breaks.last().map(|range| range.1), Some(input.len()));
assert!(breaks.windows(2).all(|pair| pair[0].1 == pair[1].0));
assert_eq!(
breaks
.iter()
.map(|&(start, end)| input.slice(start, end))
.collect::<String>(),
input.value()
);
for cursor in 0..=input.len() {
input.set_cursor(cursor);
let _ = input.wrap_cursor_from_breaks(&breaks);
}
}
#[test]
fn wrap_height_is_at_least_one() {
let i = input("");
assert_eq!(i.wrap_height(10), 1);
let i = input("hello world again");
assert!(i.wrap_height(6) >= 2);
}
#[test]
fn wrap_cursor_tracks_rows() {
let mut i = input("aaaa bbbb cccc");
i.home();
let (row, col) = i.wrap_cursor(5);
assert_eq!((row, col), (0, 0));
i.end();
let (row, _) = i.wrap_cursor(5);
assert!(row >= 1);
}
}