use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::wiz::types::{WizPhase, WizState, WizQuestion};
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn word_start_left(text: &str, pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if pos == 0 {
return 0;
}
let mut index = pos.min(chars.len());
while index > 0 && chars[index - 1].is_whitespace() {
index -= 1;
}
while index > 0 && !chars[index - 1].is_whitespace() {
index -= 1;
}
index
}
fn word_end_right(text: &str, pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if pos >= len {
return len;
}
let mut index = pos;
while index < len && !chars[index].is_whitespace() {
index += 1;
}
while index < len && chars[index].is_whitespace() {
index += 1;
}
index
}
impl WizState {
pub fn new(questions: Vec<WizQuestion>) -> Self {
let mut answers = std::collections::HashMap::new();
for q in &questions {
if !q.prefill.is_empty() {
answers.insert(q.id.clone(), q.prefill.clone());
}
}
let text_buffer = questions
.first()
.and_then(|q| answers.get(&q.id))
.cloned()
.unwrap_or_default();
let text_cursor = text_buffer.chars().count();
WizState {
questions,
answers,
selected_tab: 0,
show_review: false,
text_buffer,
text_cursor,
phase: WizPhase::Questions,
error: None,
}
}
pub fn save_current_buffer(&mut self) {
let id = self.questions[self.selected_tab].id.clone();
if !self.text_buffer.is_empty() {
self.answers.insert(id, self.text_buffer.clone());
} else {
self.answers.remove(&id);
}
}
pub fn switch_tab(&mut self, new_tab: usize) {
if new_tab >= self.questions.len() {
return;
}
self.save_current_buffer();
self.selected_tab = new_tab;
self.show_review = false;
let new_id = &self.questions[new_tab].id;
self.text_buffer = self.answers.get(new_id).cloned().unwrap_or_default();
self.text_cursor = self.text_buffer.chars().count();
}
pub fn all_answered(&self) -> bool {
self.questions.iter().all(|q| {
self.answers.get(&q.id).map(|s| !s.is_empty()).unwrap_or(false)
})
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
if matches!(self.phase, WizPhase::Done | WizPhase::Aborted) {
return true;
}
if self.phase == WizPhase::Writing {
return false;
}
match key.code {
KeyCode::Esc => {
self.phase = WizPhase::Aborted;
}
KeyCode::Tab => {
if !self.show_review {
let max = self.questions.len() - 1;
if self.selected_tab < max {
self.switch_tab(self.selected_tab + 1);
} else {
self.save_current_buffer();
self.show_review = true;
}
}
}
KeyCode::BackTab => {
if self.show_review {
let last = self.questions.len() - 1;
self.selected_tab = last;
let id = &self.questions[last].id;
self.text_buffer = self.answers.get(id).cloned().unwrap_or_default();
self.text_cursor = self.text_buffer.chars().count();
self.show_review = false;
} else if self.selected_tab > 0 {
self.switch_tab(self.selected_tab - 1);
}
}
KeyCode::Enter => {
if self.show_review {
self.phase = WizPhase::Writing;
} else {
self.save_current_buffer();
let next_unanswered = self.questions.iter().position(|q| {
!self.answers.get(&q.id).map(|s| !s.is_empty()).unwrap_or(false)
});
match next_unanswered {
Some(idx) => {
self.selected_tab = idx;
let id = &self.questions[idx].id;
self.text_buffer = self.answers.get(id).cloned().unwrap_or_default();
self.text_cursor = self.text_buffer.chars().count();
}
None => {
self.show_review = true;
}
}
}
}
KeyCode::Left
if !self.show_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
self.text_cursor = word_start_left(&self.text_buffer, self.text_cursor);
}
KeyCode::Right
if !self.show_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
self.text_cursor = word_end_right(&self.text_buffer, self.text_cursor);
}
KeyCode::Left if !self.show_review => {
if self.text_cursor > 0 {
self.text_cursor -= 1;
}
}
KeyCode::Right if !self.show_review => {
let len = self.text_buffer.chars().count();
if self.text_cursor < len {
self.text_cursor += 1;
}
}
KeyCode::Backspace
if !self.show_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
let chars: Vec<char> = self.text_buffer.chars().collect();
let new_cursor = word_start_left(&self.text_buffer, self.text_cursor);
let mut delete_end = self.text_cursor.min(chars.len());
while delete_end < chars.len() && chars[delete_end].is_whitespace() {
delete_end += 1;
}
let byte_start = char_to_byte_index(&self.text_buffer, new_cursor);
let byte_end = char_to_byte_index(&self.text_buffer, delete_end);
self.text_buffer.drain(byte_start..byte_end);
self.text_cursor = new_cursor;
}
KeyCode::Char('w')
if !self.show_review && key.modifiers.contains(KeyModifiers::CONTROL) =>
{
let chars: Vec<char> = self.text_buffer.chars().collect();
let new_cursor = word_start_left(&self.text_buffer, self.text_cursor);
let mut delete_end = self.text_cursor.min(chars.len());
while delete_end < chars.len() && chars[delete_end].is_whitespace() {
delete_end += 1;
}
let byte_start = char_to_byte_index(&self.text_buffer, new_cursor);
let byte_end = char_to_byte_index(&self.text_buffer, delete_end);
self.text_buffer.drain(byte_start..byte_end);
self.text_cursor = new_cursor;
}
KeyCode::Backspace if !self.show_review => {
if self.text_cursor > 0 {
let byte_end = char_to_byte_index(&self.text_buffer, self.text_cursor);
let byte_start = char_to_byte_index(&self.text_buffer, self.text_cursor - 1);
self.text_buffer.drain(byte_start..byte_end);
self.text_cursor -= 1;
}
}
KeyCode::Delete
if !self.show_review
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)) =>
{
let new_cursor = word_end_right(&self.text_buffer, self.text_cursor);
let byte_start = char_to_byte_index(&self.text_buffer, self.text_cursor);
let byte_end = char_to_byte_index(&self.text_buffer, new_cursor);
self.text_buffer.drain(byte_start..byte_end);
}
KeyCode::Delete if !self.show_review => {
let len = self.text_buffer.chars().count();
if self.text_cursor < len {
let byte_start = char_to_byte_index(&self.text_buffer, self.text_cursor);
let byte_end =
char_to_byte_index(&self.text_buffer, self.text_cursor + 1);
self.text_buffer.drain(byte_start..byte_end);
}
}
KeyCode::Char(c) if !self.show_review
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
let byte_pos = char_to_byte_index(&self.text_buffer, self.text_cursor);
self.text_buffer.insert(byte_pos, c);
self.text_cursor += 1;
}
_ => {}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use crate::wiz::types::{WizPhase, WizQuestion, WizSection};
fn make_questions() -> Vec<WizQuestion> {
vec![
WizQuestion { id: "q1".to_string(), prompt: "Q1".to_string(), section: WizSection::Harness, prefill: "".to_string() },
WizQuestion { id: "q2".to_string(), prompt: "Q2".to_string(), section: WizSection::Harness, prefill: "prefilled".to_string() },
WizQuestion { id: "q3".to_string(), prompt: "Q3".to_string(), section: WizSection::Global, prefill: "".to_string() },
]
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn press_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn test_new_preloads_prefill_answers() {
let state = WizState::new(make_questions());
assert_eq!(state.answers.get("q2").map(|s| s.as_str()), Some("prefilled"));
assert!(state.answers.get("q1").is_none());
}
#[test]
fn test_new_sets_initial_text_buffer_from_first_answer() {
let state = WizState::new(make_questions());
assert_eq!(state.text_buffer, "");
assert_eq!(state.text_cursor, 0);
}
#[test]
fn test_typing_inserts_chars() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('h')));
state.handle_key(press(KeyCode::Char('i')));
assert_eq!(state.text_buffer, "hi");
assert_eq!(state.text_cursor, 2);
}
#[test]
fn test_backspace_removes_char_before_cursor() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Char('b')));
state.handle_key(press(KeyCode::Backspace));
assert_eq!(state.text_buffer, "a");
assert_eq!(state.text_cursor, 1);
}
#[test]
fn test_left_right_move_cursor() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Char('b')));
assert_eq!(state.text_cursor, 2);
state.handle_key(press(KeyCode::Left));
assert_eq!(state.text_cursor, 1);
state.handle_key(press(KeyCode::Right));
assert_eq!(state.text_cursor, 2);
}
#[test]
fn test_tab_advances_tab() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('x')));
state.handle_key(press(KeyCode::Tab));
assert_eq!(state.selected_tab, 1);
assert!(!state.show_review);
assert_eq!(state.text_buffer, "prefilled");
}
#[test]
fn test_tab_saves_buffer_before_switching() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Tab));
assert_eq!(state.answers.get("q1").map(|s| s.as_str()), Some("a"));
}
#[test]
fn test_tab_from_last_goes_to_review() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Tab)); state.handle_key(press(KeyCode::Tab)); state.handle_key(press(KeyCode::Tab)); assert!(state.show_review);
}
#[test]
fn test_enter_advances_to_next_unanswered_question() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('c')));
state.handle_key(press(KeyCode::Char('l')));
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Char('u')));
state.handle_key(press(KeyCode::Char('d')));
state.handle_key(press(KeyCode::Char('e')));
state.handle_key(press(KeyCode::Enter));
assert_eq!(state.answers.get("q1").map(|s| s.as_str()), Some("claude"));
assert_eq!(state.selected_tab, 2);
assert_eq!(state.text_buffer, "");
assert_eq!(state.text_cursor, 0);
assert!(!state.show_review);
}
#[test]
fn test_backtab_from_review_returns_to_last_tab() {
let mut state = WizState::new(make_questions());
state.show_review = true;
state.handle_key(press(KeyCode::BackTab));
assert!(!state.show_review);
assert_eq!(state.selected_tab, 2);
}
#[test]
fn test_enter_in_review_sets_writing_phase() {
let mut state = WizState::new(make_questions());
state.show_review = true;
state.handle_key(press(KeyCode::Enter));
assert_eq!(state.phase, WizPhase::Writing);
}
#[test]
fn test_esc_sets_aborted_phase() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Esc));
assert_eq!(state.phase, WizPhase::Aborted);
}
#[test]
fn test_handle_key_returns_true_in_done_phase() {
let mut state = WizState::new(make_questions());
state.phase = WizPhase::Done;
assert!(state.handle_key(press(KeyCode::Enter)));
}
#[test]
fn test_insert_at_cursor_middle() {
let mut state = WizState::new(make_questions());
state.handle_key(press(KeyCode::Char('a')));
state.handle_key(press(KeyCode::Char('c')));
state.handle_key(press(KeyCode::Left));
state.handle_key(press(KeyCode::Char('b')));
assert_eq!(state.text_buffer, "abc");
assert_eq!(state.text_cursor, 2);
}
#[test]
fn test_delete_removes_character_at_cursor() {
let mut state = WizState::new(make_questions());
state.text_buffer = "abcd".to_string();
state.text_cursor = 1;
state.handle_key(press(KeyCode::Delete));
assert_eq!(state.text_buffer, "acd");
assert_eq!(state.text_cursor, 1);
}
#[test]
fn test_ctrl_left_and_right_move_by_word() {
let mut state = WizState::new(make_questions());
state.text_buffer = "alpha beta gamma".to_string();
state.text_cursor = state.text_buffer.chars().count();
state.handle_key(press_with_modifiers(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(state.text_cursor, 11);
state.handle_key(press_with_modifiers(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(state.text_cursor, 6);
state.handle_key(press_with_modifiers(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(state.text_cursor, 11);
}
#[test]
fn test_ctrl_backspace_and_delete_remove_words() {
let mut state = WizState::new(make_questions());
state.text_buffer = "alpha beta gamma".to_string();
state.text_cursor = 10;
state.handle_key(press_with_modifiers(KeyCode::Backspace, KeyModifiers::CONTROL));
assert_eq!(state.text_buffer, "alpha gamma");
assert_eq!(state.text_cursor, 6);
state.handle_key(press_with_modifiers(KeyCode::Delete, KeyModifiers::CONTROL));
assert_eq!(state.text_buffer, "alpha ");
assert_eq!(state.text_cursor, 6);
}
#[test]
fn test_alt_word_bindings_match_ctrl_behavior() {
let mut state = WizState::new(make_questions());
state.text_buffer = "alpha beta".to_string();
state.text_cursor = state.text_buffer.chars().count();
state.handle_key(press_with_modifiers(KeyCode::Left, KeyModifiers::ALT));
assert_eq!(state.text_cursor, 6);
state.handle_key(press_with_modifiers(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(state.text_buffer, "beta");
assert_eq!(state.text_cursor, 0);
}
#[test]
fn test_alt_right_and_delete_remove_next_word() {
let mut state = WizState::new(make_questions());
state.text_buffer = "alpha beta gamma".to_string();
state.text_cursor = 0;
state.handle_key(press_with_modifiers(KeyCode::Right, KeyModifiers::ALT));
assert_eq!(state.text_cursor, 6);
state.handle_key(press_with_modifiers(KeyCode::Delete, KeyModifiers::ALT));
assert_eq!(state.text_buffer, "alpha gamma");
assert_eq!(state.text_cursor, 6);
}
#[test]
fn test_ctrl_w_deletes_previous_word() {
let mut state = WizState::new(make_questions());
state.text_buffer = "alpha beta gamma".to_string();
state.text_cursor = 10;
state.handle_key(press_with_modifiers(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(state.text_buffer, "alpha gamma");
assert_eq!(state.text_cursor, 6);
}
}