#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation;
use crate::{sanitize_str, str_display_width};
pub const TEXT_EDIT_MAX_WIDTH: usize = 16_384;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextEditValue {
text: String,
}
impl TextEditValue {
pub fn new(text: impl AsRef<str>) -> Self {
Self {
text: sanitize_str(text.as_ref(), TEXT_EDIT_MAX_WIDTH),
}
}
pub fn as_str(&self) -> &str {
&self.text
}
pub fn into_string(self) -> String {
self.text
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn display_width(&self) -> usize {
str_display_width(&self.text).min(TEXT_EDIT_MAX_WIDTH)
}
pub fn grapheme_count(&self) -> usize {
self.text.graphemes(true).count()
}
pub fn byte_index(&self, grapheme_index: usize) -> usize {
if grapheme_index == 0 {
return 0;
}
self.text
.grapheme_indices(true)
.nth(grapheme_index.min(self.grapheme_count()))
.map(|(index, _)| index)
.unwrap_or(self.text.len())
}
pub fn slice(&self, start: usize, end: usize) -> &str {
let start = start.min(self.grapheme_count());
let end = end.min(self.grapheme_count()).max(start);
&self.text[self.byte_index(start)..self.byte_index(end)]
}
pub fn previous_word_start(&self, index: usize) -> usize {
let index = index.min(self.grapheme_count());
let prefix = &self.text[..self.byte_index(index)];
UnicodeSegmentation::split_word_bound_indices(prefix)
.rfind(|(_, word)| !word.trim().is_empty())
.map(|(byte_start, _)| prefix[..byte_start].graphemes(true).count())
.unwrap_or(0)
}
pub fn next_word_end(&self, index: usize) -> usize {
let index = index.min(self.grapheme_count());
let byte_start = self.byte_index(index);
let suffix = &self.text[byte_start..];
UnicodeSegmentation::split_word_bound_indices(suffix)
.find(|(_, word)| !word.trim().is_empty())
.map(|(local_start, word)| {
index + suffix[..local_start + word.len()].graphemes(true).count()
})
.unwrap_or_else(|| self.grapheme_count())
}
pub fn insert_str(&mut self, index: usize, text: impl AsRef<str>) -> usize {
let remaining_width = TEXT_EDIT_MAX_WIDTH.saturating_sub(self.display_width());
if remaining_width == 0 {
return 0;
}
let inserted = sanitize_str(text.as_ref(), remaining_width);
if inserted.is_empty() {
return 0;
}
let byte_index = self.byte_index(index);
let inserted_graphemes = inserted.graphemes(true).count();
self.text.insert_str(byte_index, &inserted);
inserted_graphemes
}
pub fn remove_range(&mut self, start: usize, end: usize) -> bool {
let start = start.min(self.grapheme_count());
let end = end.min(self.grapheme_count()).max(start);
if start == end {
return false;
}
let range = self.byte_index(start)..self.byte_index(end);
self.text.replace_range(range, "");
true
}
pub fn secure_text(&self, mask: char) -> String {
std::iter::repeat(mask)
.take(self.grapheme_count())
.collect()
}
}
impl From<&str> for TextEditValue {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl std::fmt::Display for TextEditValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextEditSelection {
pub anchor: usize,
pub focus: usize,
}
impl TextEditSelection {
pub const fn new(anchor: usize, focus: usize) -> Self {
Self { anchor, focus }
}
pub const fn collapsed(cursor: usize) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(self) -> bool {
self.anchor == self.focus
}
pub fn start(self) -> usize {
self.anchor.min(self.focus)
}
pub fn end(self) -> usize {
self.anchor.max(self.focus)
}
pub fn clamped(self, grapheme_count: usize) -> Self {
Self {
anchor: self.anchor.min(grapheme_count),
focus: self.focus.min(grapheme_count),
}
}
pub fn byte_range(self, value: &TextEditValue) -> std::ops::Range<usize> {
let selection = self.clamped(value.grapheme_count());
value.byte_index(selection.start())..value.byte_index(selection.end())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextPreedit {
pub text: String,
pub cursor: Option<usize>,
}
impl TextPreedit {
pub fn new(text: impl AsRef<str>, cursor: Option<usize>) -> Self {
let text = sanitize_str(text.as_ref(), 512);
let graphemes = text.graphemes(true).count();
Self {
text,
cursor: cursor.map(|cursor| cursor.min(graphemes)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextEditModel {
pub value: TextEditValue,
pub cursor: usize,
pub selection: Option<TextEditSelection>,
pub placeholder: String,
pub secure: bool,
pub preedit: Option<TextPreedit>,
}
impl TextEditModel {
pub fn new(value: impl AsRef<str>) -> Self {
let value = TextEditValue::new(value);
let cursor = value.grapheme_count();
Self {
value,
cursor,
selection: None,
placeholder: String::new(),
secure: false,
preedit: None,
}
}
pub fn with_placeholder(mut self, placeholder: impl AsRef<str>) -> Self {
self.placeholder = sanitize_str(placeholder.as_ref(), 160);
self
}
pub fn secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn clamped_cursor(&self) -> usize {
self.cursor.min(self.value.grapheme_count())
}
pub fn set_cursor(&mut self, cursor: usize) {
self.cursor = cursor.min(self.value.grapheme_count());
self.selection = None;
}
pub fn set_selection(&mut self, selection: TextEditSelection) {
let selection = selection.clamped(self.value.grapheme_count());
self.cursor = selection.focus;
self.selection = (!selection.is_collapsed()).then_some(selection);
}
pub fn selected_text(&self) -> Option<&str> {
let selection = self.selection?.clamped(self.value.grapheme_count());
(!selection.is_collapsed()).then(|| &self.value.as_str()[selection.byte_range(&self.value)])
}
pub fn display_text(&self, secure_mask: char) -> String {
let mut text = if self.value.is_empty() && self.placeholder.is_empty() {
String::new()
} else if self.value.is_empty() {
self.placeholder.clone()
} else if self.secure {
self.value.secure_text(secure_mask)
} else {
self.value.as_str().to_string()
};
if let Some(preedit) = &self.preedit {
let insert_at = TextEditValue::new(&text).byte_index(self.clamped_cursor());
text.insert_str(insert_at, &preedit.text);
}
text
}
pub fn move_left(&mut self, selecting: bool) {
self.move_to(self.clamped_cursor().saturating_sub(1), selecting);
}
pub fn move_right(&mut self, selecting: bool) {
self.move_to(self.clamped_cursor().saturating_add(1), selecting);
}
pub fn move_to_start(&mut self, selecting: bool) {
self.move_to(0, selecting);
}
pub fn move_to_end(&mut self, selecting: bool) {
self.move_to(self.value.grapheme_count(), selecting);
}
pub fn move_to_previous_word(&mut self, selecting: bool) {
self.move_to(
self.value.previous_word_start(self.clamped_cursor()),
selecting,
);
}
pub fn move_to_next_word(&mut self, selecting: bool) {
self.move_to(self.value.next_word_end(self.clamped_cursor()), selecting);
}
pub fn insert_text(&mut self, text: impl AsRef<str>) -> bool {
let removed = self.delete_selection();
let inserted = self.value.insert_str(self.clamped_cursor(), text);
if inserted == 0 {
return removed;
}
self.cursor = self.clamped_cursor().saturating_add(inserted);
true
}
pub fn backspace(&mut self) -> bool {
if self.delete_selection() {
return true;
}
let cursor = self.clamped_cursor();
if cursor == 0 {
return false;
}
if self.value.remove_range(cursor - 1, cursor) {
self.cursor = cursor - 1;
true
} else {
false
}
}
pub fn delete_forward(&mut self) -> bool {
if self.delete_selection() {
return true;
}
let cursor = self.clamped_cursor();
if self.value.remove_range(cursor, cursor + 1) {
self.cursor = cursor;
true
} else {
false
}
}
pub fn delete_selection(&mut self) -> bool {
let Some(selection) = self.selection.take() else {
self.cursor = self.clamped_cursor();
return false;
};
let selection = selection.clamped(self.value.grapheme_count());
let start = selection.start();
let removed = self.value.remove_range(start, selection.end());
self.cursor = start.min(self.value.grapheme_count());
removed
}
pub fn set_preedit(&mut self, preedit: Option<TextPreedit>) {
self.preedit = preedit;
}
pub fn commit_preedit(&mut self) -> bool {
let Some(preedit) = self.preedit.take() else {
return false;
};
self.insert_text(preedit.text)
}
fn move_to(&mut self, cursor: usize, selecting: bool) {
let cursor = cursor.min(self.value.grapheme_count());
if selecting {
let anchor = self
.selection
.map(|selection| selection.anchor)
.unwrap_or_else(|| self.clamped_cursor());
self.selection = Some(TextEditSelection::new(anchor, cursor))
.filter(|selection| !selection.is_collapsed());
} else {
self.selection = None;
}
self.cursor = cursor;
}
}
impl Default for TextEditModel {
fn default() -> Self {
Self::new("")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_value_tracks_grapheme_boundaries() {
let mut value = TextEditValue::new("é b");
assert_eq!(value.grapheme_count(), 3);
assert_eq!(value.slice(0, 1), "é");
assert_eq!(value.previous_word_start(3), 2);
assert_eq!(value.next_word_end(0), 1);
assert_eq!(value.insert_str(1, "x"), 1);
assert_eq!(value.as_str(), "éx b");
assert!(value.remove_range(0, 1));
assert_eq!(value.as_str(), "x b");
}
#[test]
fn edit_model_replaces_selection_and_moves_by_words() {
let mut model = TextEditModel::new("hello world");
model.set_selection(TextEditSelection::new(6, 11));
assert_eq!(model.selected_text(), Some("world"));
assert!(model.insert_text("rae"));
assert_eq!(model.value.as_str(), "hello rae");
assert_eq!(model.cursor, 9);
model.move_to_previous_word(false);
assert_eq!(model.cursor, 6);
model.move_to_next_word(true);
assert_eq!(model.selected_text(), Some("rae"));
}
#[test]
fn edit_model_backspace_and_delete_are_grapheme_safe() {
let mut model = TextEditModel::new("éb");
model.set_cursor(1);
assert!(model.backspace());
assert_eq!(model.value.as_str(), "b");
assert_eq!(model.cursor, 0);
assert!(model.delete_forward());
assert!(model.value.is_empty());
}
#[test]
fn edit_model_secure_display_and_preedit_are_separate_from_value() {
let mut model = TextEditModel::new("secret").secure(true);
model.set_cursor(3);
model.set_preedit(Some(TextPreedit::new("!", Some(1))));
assert_eq!(model.display_text('*'), "***!***");
assert_eq!(model.value.as_str(), "secret");
assert!(model.commit_preedit());
assert_eq!(model.value.as_str(), "sec!ret");
}
#[test]
fn edit_model_sanitizes_inserted_text_and_caps_width() {
let mut model = TextEditModel::new("ready");
assert!(model.insert_text("\n\u{200f}\x1b[31mgo"));
assert_eq!(model.value.as_str(), "ready go");
let mut full = TextEditModel::new("x".repeat(TEXT_EDIT_MAX_WIDTH + 100));
full.move_to_end(false);
assert!(!full.insert_text("more"));
assert_eq!(full.value.display_width(), TEXT_EDIT_MAX_WIDTH);
}
}