use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::tui::theme::C_WHITE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EditOutcome {
Pending,
Commit,
Cancel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LineEdit {
buffer: String,
cursor: usize,
pub(crate) masked: bool,
}
impl LineEdit {
pub(crate) fn new(initial: impl Into<String>, masked: bool) -> Self {
let buffer = initial.into();
let cursor = buffer.chars().count();
Self {
buffer,
cursor,
masked,
}
}
pub(crate) fn value(&self) -> &str {
&self.buffer
}
fn char_len(&self) -> usize {
self.buffer.chars().count()
}
fn byte_at(&self, char_idx: usize) -> usize {
self.buffer
.char_indices()
.nth(char_idx)
.map_or(self.buffer.len(), |(i, _)| i)
}
pub(crate) fn handle_key(&mut self, key: &KeyEvent) -> EditOutcome {
match key.code {
KeyCode::Enter => return EditOutcome::Commit,
KeyCode::Esc => return EditOutcome::Cancel,
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.char_len()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.char_len(),
KeyCode::Backspace => {
if self.cursor > 0 {
let start = self.byte_at(self.cursor - 1);
let end = self.byte_at(self.cursor);
self.buffer.replace_range(start..end, "");
self.cursor -= 1;
}
}
KeyCode::Delete => {
if self.cursor < self.char_len() {
let start = self.byte_at(self.cursor);
let end = self.byte_at(self.cursor + 1);
self.buffer.replace_range(start..end, "");
}
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let at = self.byte_at(self.cursor);
self.buffer.insert(at, c);
self.cursor += 1;
}
_ => {}
}
EditOutcome::Pending
}
pub(crate) fn display_spans(&self, reveal: bool) -> Line<'static> {
let shown: String = if self.masked && !reveal {
"•".repeat(self.char_len())
} else {
self.buffer.clone()
};
let before: String = shown.chars().take(self.cursor).collect();
let at: String = shown.chars().skip(self.cursor).take(1).collect();
let after: String = shown.chars().skip(self.cursor + 1).collect();
let cursor_cell = if at.is_empty() { " ".to_string() } else { at };
let plain = Style::default().fg(C_WHITE);
Line::from(vec![
Span::styled(before, plain),
Span::styled(cursor_cell, plain.add_modifier(Modifier::REVERSED)),
Span::styled(after, plain),
])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
fn type_str(edit: &mut LineEdit, s: &str) {
for c in s.chars() {
assert_eq!(
edit.handle_key(&press(KeyCode::Char(c))),
EditOutcome::Pending
);
}
}
#[test]
fn typing_appends_and_the_cursor_starts_at_the_end_of_the_initial_value() {
let mut edit = LineEdit::new("sk-", true);
type_str(&mut edit, "abc");
assert_eq!(edit.value(), "sk-abc");
}
#[test]
fn arrows_home_and_end_move_the_insertion_point() {
let mut edit = LineEdit::new("ad", false);
edit.handle_key(&press(KeyCode::Left));
type_str(&mut edit, "bc");
assert_eq!(edit.value(), "abcd");
edit.handle_key(&press(KeyCode::Home));
type_str(&mut edit, "x");
assert_eq!(edit.value(), "xabcd");
edit.handle_key(&press(KeyCode::End));
type_str(&mut edit, "z");
assert_eq!(edit.value(), "xabcdz");
for _ in 0..20 {
edit.handle_key(&press(KeyCode::Left));
}
edit.handle_key(&press(KeyCode::Left));
for _ in 0..20 {
edit.handle_key(&press(KeyCode::Right));
}
edit.handle_key(&press(KeyCode::Right));
assert_eq!(edit.value(), "xabcdz");
}
#[test]
fn backspace_and_delete_remove_around_the_cursor() {
let mut edit = LineEdit::new("abc", false);
edit.handle_key(&press(KeyCode::Backspace));
assert_eq!(edit.value(), "ab");
edit.handle_key(&press(KeyCode::Home));
edit.handle_key(&press(KeyCode::Delete));
assert_eq!(edit.value(), "b");
edit.handle_key(&press(KeyCode::Backspace));
assert_eq!(edit.value(), "b");
edit.handle_key(&press(KeyCode::End));
edit.handle_key(&press(KeyCode::Delete));
assert_eq!(edit.value(), "b");
}
#[test]
fn multibyte_input_splices_on_char_boundaries() {
let mut edit = LineEdit::new("", false);
type_str(&mut edit, "héllo wörld");
assert_eq!(edit.value(), "héllo wörld");
for _ in 0..3 {
edit.handle_key(&press(KeyCode::Left));
}
edit.handle_key(&press(KeyCode::Backspace));
assert_eq!(edit.value(), "héllo wrld");
type_str(&mut edit, "ø");
assert_eq!(edit.value(), "héllo wørld");
}
#[test]
fn enter_commits_and_esc_cancels() {
let mut edit = LineEdit::new("v", false);
assert_eq!(edit.handle_key(&press(KeyCode::Enter)), EditOutcome::Commit);
assert_eq!(edit.handle_key(&press(KeyCode::Esc)), EditOutcome::Cancel);
assert_eq!(edit.value(), "v");
}
#[test]
fn control_chords_and_unmapped_keys_leave_the_buffer_alone() {
let mut edit = LineEdit::new("keep", false);
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
assert_eq!(edit.handle_key(&ctrl_s), EditOutcome::Pending);
assert_eq!(edit.handle_key(&press(KeyCode::F(2))), EditOutcome::Pending);
assert_eq!(edit.value(), "keep");
}
#[test]
fn display_masks_bullets_reveals_on_demand_and_shows_a_cursor_cell() {
let edit = LineEdit::new("ab", true);
let masked = edit.display_spans(false);
let masked_text: String = masked.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(masked_text, "•• ");
let revealed = edit.display_spans(true);
let revealed_text: String = revealed.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(revealed_text, "ab ");
let mut plain = LineEdit::new("xy", false);
plain.handle_key(&press(KeyCode::Home));
let line = plain.display_spans(false);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "xy");
assert_eq!(line.spans[1].content.as_ref(), "x");
}
}