use tui_input::{Input, InputRequest};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::app::KeyPress;
const CURSOR: char = '\u{258f}';
const MASK: char = '\u{2588}';
const MIN_BOX: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextEdit {
Left,
Right,
WordLeft,
WordRight,
Home,
End,
Delete,
DeleteWordBack,
DeleteToStart,
DeleteToEnd,
Reveal,
}
#[derive(Clone, Default)]
pub struct TextBox {
input: Input,
revealed: bool,
}
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),
revealed: false,
}
}
}
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::Reveal => {
self.revealed = !self.revealed;
return;
}
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 {
self.scrolled(self.as_str().chars().collect(), width)
}
#[must_use]
pub fn drawn_masked(&self, width: u16) -> String {
self.scrolled(vec![MASK; self.char_count()], width)
}
#[must_use]
pub fn drawn_for(&self, secret: bool, width: u16) -> String {
if secret && !self.revealed {
self.drawn_masked(width)
} else {
self.drawn(width)
}
}
#[must_use]
pub fn shown(&self, secret: bool) -> String {
if secret && !self.revealed {
MASK.to_string().repeat(self.char_count())
} else {
self.as_str().to_owned()
}
}
fn scrolled(&self, mut cells: Vec<char>, width: u16) -> String {
let width = usize::from(width);
let at = self.cursor();
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, secret: bool, 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_for(secret, 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_masked_cell_keeps_the_cursor_and_its_scroll() {
assert_eq!(
boxed("hunter2", 3).drawn_masked(80),
"\u{2588}\u{2588}\u{2588}\u{258f}\u{2588}\u{2588}\u{2588}\u{2588}"
);
assert_eq!(TextBox::new().drawn_masked(80), "\u{258f}");
assert_eq!(
boxed("hello world", 11).drawn_masked(6),
"\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{258f}"
);
assert_eq!(
boxed("hello world", 0).drawn_masked(6),
"\u{258f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}"
);
assert_eq!(
boxed("hello world", 8).drawn_masked(6),
"\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{258f}"
);
assert_eq!(TextBox::from("abc").drawn_masked(0), "");
}
#[test]
fn a_masked_wide_character_is_one_block_and_nothing_else_survives() {
let masked = TextBox::from("\u{65e5}\u{672c}\u{8a9e}").drawn_masked(5);
assert_eq!(masked, "\u{2588}\u{2588}\u{2588}\u{258f}");
assert!(!masked.contains('\u{65e5}'));
}
#[test]
fn revealing_toggles_what_a_secret_draws_and_leaves_the_text_alone() {
let mut text_box = boxed("ab", 1);
assert_eq!(text_box.drawn_for(true, 80), "\u{2588}\u{258f}\u{2588}");
text_box.edit(TextEdit::Reveal);
assert_eq!(text_box.drawn_for(true, 80), "a\u{258f}b");
assert_eq!(text_box.drawn_for(false, 80), "a\u{258f}b");
text_box.edit(TextEdit::Reveal);
assert_eq!(text_box.drawn_for(true, 80), "\u{2588}\u{258f}\u{2588}");
assert_eq!(text_box.as_str(), "ab");
assert_eq!(text_box.cursor(), 1);
}
#[test]
fn a_secret_box_shown_without_a_cursor_is_a_run_of_blocks() {
let mut text_box = TextBox::from("hunter2");
assert_eq!(text_box.shown(true), "\u{2588}".repeat(7));
assert_eq!(text_box.shown(false), "hunter2");
text_box.edit(TextEdit::Reveal);
assert_eq!(text_box.shown(true), "hunter2");
}
#[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(false, "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(false, "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(false, "f ", " enter applies esc cancels", 20);
assert_eq!(
line,
"f 456789abcdefghij\u{258f} enter applies esc cancels"
);
}
#[test]
fn a_secret_line_masks_the_box_and_leaves_both_ends_alone() {
let mut text_box = boxed("hunter2", 3);
assert_eq!(
text_box.line(true, "env ", " enter writes", 40),
"env \u{2588}\u{2588}\u{2588}\u{258f}\u{2588}\u{2588}\u{2588}\u{2588} enter writes"
);
text_box.edit(TextEdit::Reveal);
assert_eq!(text_box.line(true, "env ", "", 40), "env hun\u{258f}ter2");
}
#[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}");
}
}