use tui_input::{Input, InputRequest};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::app::KeyPress;
const CURSOR: char = '\u{258f}';
const MIN_BOX: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextEdit {
Left,
Right,
WordLeft,
WordRight,
Home,
End,
Delete,
DeleteWordBack,
DeleteToStart,
DeleteToEnd,
}
#[derive(Clone, Default)]
pub struct TextBox {
input: Input,
}
impl core::fmt::Debug for TextBox {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"TextBox {{ chars: {}, cursor: {} }}",
self.char_count(),
self.cursor()
)
}
}
impl PartialEq for TextBox {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str() && self.cursor() == other.cursor()
}
}
impl Eq for TextBox {}
impl From<&str> for TextBox {
fn from(text: &str) -> Self {
Self::from(text.to_owned())
}
}
impl From<String> for TextBox {
fn from(text: String) -> Self {
Self {
input: Input::new(text),
}
}
}
impl TextBox {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn as_str(&self) -> &str {
self.input.value()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.as_str().is_empty()
}
#[must_use]
pub fn char_count(&self) -> usize {
self.as_str().chars().count()
}
#[must_use]
pub fn cursor(&self) -> usize {
self.input.cursor()
}
pub fn insert(&mut self, typed: char) {
self.input.handle(InputRequest::InsertChar(typed));
}
pub fn backspace(&mut self) {
self.input.handle(InputRequest::DeletePrevChar);
}
pub fn edit(&mut self, edit: TextEdit) {
let request = match edit {
TextEdit::Left => InputRequest::GoToPrevChar,
TextEdit::Right => InputRequest::GoToNextChar,
TextEdit::WordLeft => InputRequest::GoToPrevWord,
TextEdit::WordRight => InputRequest::GoToNextWord,
TextEdit::Home => InputRequest::GoToStart,
TextEdit::End => InputRequest::GoToEnd,
TextEdit::Delete => InputRequest::DeleteNextChar,
TextEdit::DeleteWordBack => InputRequest::DeletePrevWord,
TextEdit::DeleteToStart => InputRequest::DeleteFromStart,
TextEdit::DeleteToEnd => InputRequest::DeleteTillEnd,
};
self.input.handle(request);
}
pub fn handle(&mut self, key: KeyPress) {
match key {
KeyPress::TextChar(typed) => self.insert(typed),
KeyPress::TextBackspace => self.backspace(),
KeyPress::TextEdit(edit) => self.edit(edit),
_ => {}
}
}
pub fn pasted(text: &str) -> impl Iterator<Item = char> + '_ {
text.chars().filter(|typed| !typed.is_control())
}
#[must_use]
pub fn drawn(&self, width: u16) -> String {
let width = usize::from(width);
let at = self.cursor();
let mut cells: Vec<char> = self.as_str().chars().collect();
cells.insert(at, CURSOR);
let cursor_column: usize = cells[..at].iter().map(|c| c.width().unwrap_or(0)).sum();
let scroll = (cursor_column + 1).saturating_sub(width);
let mut column = 0;
let mut drawn = String::new();
let mut drawn_width = 0;
for cell in cells {
let cell_width = cell.width().unwrap_or(0);
if column < scroll {
column += cell_width;
continue;
}
if drawn_width + cell_width > width {
break;
}
drawn.push(cell);
drawn_width += cell_width;
}
drawn
}
}
impl TextBox {
#[must_use]
pub fn line(&self, before: &str, after: &str, width: u16) -> String {
let columns = |text: &str| u16::try_from(text.width()).unwrap_or(u16::MAX);
let room = width.saturating_sub(columns(before));
let beside = room.saturating_sub(columns(after));
let budget = if beside >= MIN_BOX { beside } else { room };
format!("{before}{}{after}", self.drawn(budget))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn boxed(text: &str, cursor: usize) -> TextBox {
let mut text_box = TextBox::from(text);
for _ in cursor..text.chars().count() {
text_box.edit(TextEdit::Left);
}
text_box
}
#[test]
fn a_character_typed_in_the_middle_lands_at_the_cursor() {
let mut text_box = boxed("acd", 1);
text_box.insert('b');
assert_eq!(text_box.as_str(), "abcd");
assert_eq!(text_box.cursor(), 2);
}
#[test]
fn the_word_moves_land_on_word_starts_and_the_ends() {
let mut text_box = TextBox::from("foo bar baz");
text_box.edit(TextEdit::WordLeft);
assert_eq!(text_box.cursor(), 8);
text_box.edit(TextEdit::WordLeft);
assert_eq!(text_box.cursor(), 4);
text_box.edit(TextEdit::WordRight);
assert_eq!(text_box.cursor(), 8);
text_box.edit(TextEdit::WordRight);
assert_eq!(text_box.cursor(), 11);
text_box.edit(TextEdit::Home);
assert_eq!(text_box.cursor(), 0);
text_box.edit(TextEdit::End);
assert_eq!(text_box.cursor(), 11);
}
#[test]
fn the_character_moves_stop_at_both_ends() {
let mut text_box = boxed("ab", 1);
text_box.edit(TextEdit::Right);
text_box.edit(TextEdit::Right);
assert_eq!(text_box.cursor(), 2);
text_box.edit(TextEdit::Left);
text_box.edit(TextEdit::Left);
text_box.edit(TextEdit::Left);
assert_eq!(text_box.cursor(), 0);
}
#[test]
fn delete_word_back_removes_the_word_before_the_cursor() {
let mut text_box = TextBox::from("foo bar");
text_box.edit(TextEdit::DeleteWordBack);
assert_eq!(text_box.as_str(), "foo ");
assert_eq!(text_box.cursor(), 4);
}
#[test]
fn delete_to_start_keeps_what_follows_the_cursor() {
let mut text_box = boxed("foo bar", 3);
text_box.edit(TextEdit::DeleteToStart);
assert_eq!(text_box.as_str(), " bar");
assert_eq!(text_box.cursor(), 0);
}
#[test]
fn delete_to_end_keeps_what_precedes_the_cursor() {
let mut text_box = boxed("foo bar", 3);
text_box.edit(TextEdit::DeleteToEnd);
assert_eq!(text_box.as_str(), "foo");
assert_eq!(text_box.cursor(), 3);
}
#[test]
fn delete_removes_the_character_under_the_cursor_and_backspace_the_one_before() {
let mut text_box = boxed("wxyz", 2);
text_box.edit(TextEdit::Delete);
assert_eq!(text_box.as_str(), "wxz");
text_box.backspace();
assert_eq!(text_box.as_str(), "wz");
assert_eq!(text_box.cursor(), 1);
}
#[test]
fn multibyte_text_moves_and_deletes_by_character() {
let mut text_box = TextBox::from("héllo wörld");
text_box.edit(TextEdit::WordLeft);
text_box.edit(TextEdit::Left);
text_box.insert('ü');
assert_eq!(text_box.as_str(), "hélloü wörld");
assert_eq!(text_box.cursor(), 6);
text_box.backspace();
text_box.edit(TextEdit::Left);
text_box.edit(TextEdit::Delete);
assert_eq!(text_box.as_str(), "héll wörld");
assert_eq!(text_box.cursor(), 4);
}
#[test]
fn backspace_takes_a_base_letter_and_its_combining_mark_together() {
let mut text_box = TextBox::from("ae\u{301}");
text_box.backspace();
assert_eq!(text_box.as_str(), "a");
}
#[test]
fn a_paste_keeps_its_printable_characters_and_drops_every_control_one() {
let kept: String = TextBox::pasted("ab\r\ncd\t\u{1b}[31me\u{7f}\u{e9}").collect();
assert_eq!(kept, "abcd[31me\u{e9}");
}
#[test]
fn debug_shows_the_size_and_never_the_text() {
let mut text_box = TextBox::from("hunter2");
text_box.edit(TextEdit::DeleteWordBack);
text_box.insert('s');
assert_eq!(format!("{text_box:?}"), "TextBox { chars: 1, cursor: 1 }");
assert_eq!(
format!("{:?}", Some(TextBox::from("hunter2"))),
"Some(TextBox { chars: 7, cursor: 7 })"
);
}
#[test]
fn equality_reads_the_text_and_the_cursor() {
assert_eq!(boxed("ab", 1), boxed("ab", 1));
assert_ne!(boxed("ab", 1), boxed("ab", 2));
assert_ne!(boxed("ab", 2), boxed("ac", 2));
}
#[test]
fn the_cursor_is_drawn_where_it_is() {
assert_eq!(boxed("hello", 2).drawn(80), "he\u{258f}llo");
assert_eq!(boxed("hello", 5).drawn(80), "hello\u{258f}");
assert_eq!(TextBox::new().drawn(80), "\u{258f}");
}
#[test]
fn a_narrow_cell_scrolls_to_keep_the_cursor_in_view() {
assert_eq!(boxed("hello world", 11).drawn(6), "world\u{258f}");
assert_eq!(boxed("hello world", 0).drawn(6), "\u{258f}hello");
assert_eq!(boxed("hello world", 8).drawn(6), "lo wo\u{258f}");
assert_eq!(boxed("jumps over", 3).drawn(6), "jum\u{258f}ps");
}
#[test]
fn a_wide_character_is_never_split_by_the_scroll() {
let drawn = TextBox::from("\u{65e5}\u{672c}\u{8a9e}").drawn(5);
assert_eq!(drawn, "\u{672c}\u{8a9e}\u{258f}");
}
#[test]
fn a_line_leaves_the_box_what_the_two_ends_leave() {
let text_box = boxed("hello", 2);
assert_eq!(
text_box.line("filter ", " enter applies", 40),
"filter he\u{258f}llo enter applies"
);
}
#[test]
fn a_line_scrolls_the_box_rather_than_cutting_it() {
let text_box = TextBox::from("0123456789abcdefghij");
let line = text_box.line("f ", " enter applies", 33);
assert_eq!(line, "f 789abcdefghij\u{258f} enter applies");
}
#[test]
fn a_line_drops_the_text_after_the_box_before_squeezing_it() {
let text_box = TextBox::from("0123456789abcdefghij");
let line = text_box.line("f ", " enter applies esc cancels", 20);
assert_eq!(
line,
"f 456789abcdefghij\u{258f} enter applies esc cancels"
);
}
#[test]
fn handle_applies_text_keys_and_ignores_the_rest() {
let mut text_box = TextBox::new();
text_box.handle(KeyPress::TextChar('a'));
text_box.handle(KeyPress::TextChar('b'));
text_box.handle(KeyPress::TextEdit(TextEdit::Left));
text_box.handle(KeyPress::TextBackspace);
text_box.handle(KeyPress::Confirm);
assert_eq!(text_box.as_str(), "b");
assert_eq!(text_box.cursor(), 0);
}
#[test]
fn a_cell_with_no_room_draws_nothing() {
assert_eq!(TextBox::from("abc").drawn(0), "");
assert_eq!(TextBox::from("abc").drawn(1), "\u{258f}");
}
}