#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InputState {
pub cursor: usize,
text: String,
}
impl InputState {
pub fn with_text(text: String) -> Self {
let cursor = text.chars().count();
Self { cursor, text }
}
pub fn text(&self) -> &str {
&self.text
}
pub fn take_text(&mut self) -> String {
self.cursor = 0;
std::mem::take(&mut self.text)
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn insert_char(&mut self, ch: char) {
let byte_offset = self.byte_offset();
self.text.insert(byte_offset, ch);
self.cursor += 1;
}
pub fn insert_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let byte_offset = self.byte_offset();
self.text.insert_str(byte_offset, text);
self.cursor += text.chars().count();
}
pub fn insert_newline(&mut self) {
self.insert_char('\n');
}
pub fn delete_backward(&mut self) {
if self.cursor == 0 {
return;
}
let start = self.byte_offset_at(self.cursor - 1);
let end = self.byte_offset();
self.text.replace_range(start..end, "");
self.cursor -= 1;
}
pub fn delete_current_line(&mut self) {
let characters: Vec<char> = self.text.chars().collect();
let cursor_pos = self.cursor.min(characters.len());
let mut line_start = cursor_pos;
while line_start > 0 && characters[line_start - 1] != '\n' {
line_start -= 1;
}
let mut line_end = cursor_pos;
while line_end < characters.len() && characters[line_end] != '\n' {
line_end += 1;
}
let (delete_start, delete_end) = if line_start > 0 {
(line_start - 1, line_end)
} else if line_end < characters.len() {
(line_start, line_end + 1)
} else {
(line_start, line_end)
};
self.replace_range(delete_start, delete_end, "");
}
pub fn delete_forward(&mut self) {
let char_count = self.text.chars().count();
if self.cursor >= char_count {
return;
}
let start = self.byte_offset();
let end = self.byte_offset_at(self.cursor + 1);
self.text.replace_range(start..end, "");
}
pub fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
let char_count = self.text.chars().count();
if self.cursor < char_count {
self.cursor += 1;
}
}
pub fn move_up(&mut self) {
let (line, column) = self.line_column();
if line == 0 {
self.cursor = 0;
return;
}
let mut current_line = 0;
let mut line_start = 0;
for (char_index, ch) in self.text.chars().enumerate() {
if current_line == line - 1 {
break;
}
if ch == '\n' {
current_line += 1;
line_start = char_index + 1;
}
}
let prev_line_start = line_start;
let prev_line_len = self
.text
.chars()
.skip(prev_line_start)
.take_while(|&c| c != '\n')
.count();
self.cursor = prev_line_start + column.min(prev_line_len);
}
pub fn move_down(&mut self) {
let (line, column) = self.line_column();
let line_count = self.text.chars().filter(|&c| c == '\n').count() + 1;
if line >= line_count - 1 {
self.cursor = self.text.chars().count();
return;
}
let mut char_index = 0;
let mut current_line = 0;
for ch in self.text.chars() {
char_index += 1;
if ch == '\n' {
current_line += 1;
if current_line == line + 1 {
break;
}
}
}
let next_line_start = char_index;
let next_line_len = self
.text
.chars()
.skip(next_line_start)
.take_while(|&c| c != '\n')
.count();
self.cursor = next_line_start + column.min(next_line_len);
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.text.chars().count();
}
pub fn move_line_start(&mut self) {
let characters: Vec<char> = self.text.chars().collect();
let mut cursor = self.cursor;
while cursor > 0 && characters[cursor - 1] != '\n' {
cursor -= 1;
}
self.cursor = cursor;
}
pub fn move_line_end(&mut self) {
let characters: Vec<char> = self.text.chars().collect();
let mut cursor = self.cursor;
while cursor < characters.len() && characters[cursor] != '\n' {
cursor += 1;
}
self.cursor = cursor;
}
pub fn delete_to_line_end(&mut self) {
let characters: Vec<char> = self.text.chars().collect();
let mut line_end = self.cursor;
while line_end < characters.len() && characters[line_end] != '\n' {
line_end += 1;
}
if line_end > self.cursor {
let start_byte = self.byte_offset();
let end_byte = self.byte_offset_at(line_end);
self.text.replace_range(start_byte..end_byte, "");
}
}
pub fn at_mention_query(&self) -> Option<(usize, String)> {
extract_at_mention_query(&self.text, self.cursor)
}
pub fn replace_range(&mut self, start_char: usize, end_char: usize, replacement: &str) {
let start_byte = self.byte_offset_at(start_char);
let end_byte = self.byte_offset_at(end_char);
self.text.replace_range(start_byte..end_byte, replacement);
self.cursor = start_char + replacement.chars().count();
}
fn byte_offset(&self) -> usize {
self.byte_offset_at(self.cursor)
}
fn byte_offset_at(&self, char_index: usize) -> usize {
self.text
.char_indices()
.nth(char_index)
.map_or(self.text.len(), |(index, _)| index)
}
fn line_column(&self) -> (usize, usize) {
let mut line = 0;
let mut column = 0;
for (index, ch) in self.text.chars().enumerate() {
if index == self.cursor {
break;
}
if ch == '\n' {
line += 1;
column = 0;
} else {
column += 1;
}
}
(line, column)
}
}
pub fn extract_at_mention_query(text: &str, cursor: usize) -> Option<(usize, String)> {
if cursor == 0 {
return None;
}
let chars: Vec<char> = text.chars().collect();
let mut scan = cursor;
while scan > 0 {
scan -= 1;
let ch = *chars.get(scan)?;
if ch == '@' {
if is_at_mention_boundary(chars.get(scan.wrapping_sub(1)).copied()) {
let query: String = chars[scan + 1..cursor].iter().collect();
return Some((scan, query));
}
return None;
}
if ch.is_whitespace() {
return None;
}
}
None
}
pub(crate) fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
previous_character.is_none_or(|ch| ch.is_whitespace() || is_at_mention_opening_delimiter(ch))
}
pub(crate) fn is_at_mention_query_character(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-')
}
fn is_at_mention_opening_delimiter(ch: char) -> bool {
matches!(ch, '(' | '[' | '{')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_text_at_end_updates_text_and_cursor() {
let mut state = InputState::with_text("hello".to_string());
state.insert_text(" world");
assert_eq!(state.text(), "hello world");
assert_eq!(state.cursor, "hello world".chars().count());
}
#[test]
fn test_insert_text_in_middle_preserves_surrounding_content() {
let mut state = InputState::with_text("hllo".to_string());
state.cursor = 1;
state.insert_text("e");
assert_eq!(state.text(), "hello");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_delete_current_line_clears_single_line_content() {
let mut state = InputState::with_text("hello world".to_string());
state.cursor = "hello".chars().count();
state.delete_current_line();
assert_eq!(state.text(), "");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_delete_current_line_removes_last_line_and_preceding_newline() {
let mut state = InputState::with_text("first line\nsecond line".to_string());
state.cursor = "first line\nsecond".chars().count();
state.delete_current_line();
assert_eq!(state.text(), "first line");
assert_eq!(state.cursor, "first line".chars().count());
}
#[test]
fn test_delete_current_line_removes_middle_line_and_preceding_newline() {
let mut state = InputState::with_text("first line\nsecond line\nthird line".to_string());
state.cursor = "first line\nsecond".chars().count();
state.delete_current_line();
assert_eq!(state.text(), "first line\nthird line");
assert_eq!(state.cursor, "first line".chars().count());
}
#[test]
fn test_delete_current_line_removes_first_line_and_following_newline() {
let mut state = InputState::with_text("first line\nsecond line".to_string());
state.cursor = "first".chars().count();
state.delete_current_line();
assert_eq!(state.text(), "second line");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_extract_at_mention_query_accepts_parenthesized_lookup() {
let text = "review (@src/main.rs)";
let cursor = "review (@src/main.rs".chars().count();
let query = extract_at_mention_query(text, cursor);
assert_eq!(query, Some((8, "src/main.rs".to_string())));
}
#[test]
fn test_extract_at_mention_query_rejects_email_pattern() {
let text = "person@example.com";
let cursor = text.chars().count();
let query = extract_at_mention_query(text, cursor);
assert_eq!(query, None);
}
#[test]
fn test_move_line_start_moves_to_beginning_of_current_line() {
let mut state = InputState::with_text("first\nsecond\nthird".to_string());
state.cursor = "first\nseco".chars().count();
state.move_line_start();
assert_eq!(state.cursor, "first\n".chars().count());
}
#[test]
fn test_move_line_start_stays_at_buffer_start_on_first_line() {
let mut state = InputState::with_text("hello world".to_string());
state.cursor = 5;
state.move_line_start();
assert_eq!(state.cursor, 0);
}
#[test]
fn test_move_line_end_moves_to_end_of_current_line() {
let mut state = InputState::with_text("first\nsecond\nthird".to_string());
state.cursor = "first\nse".chars().count();
state.move_line_end();
assert_eq!(state.cursor, "first\nsecond".chars().count());
}
#[test]
fn test_move_line_end_moves_to_buffer_end_on_last_line() {
let mut state = InputState::with_text("first\nsecond".to_string());
state.cursor = "first\nse".chars().count();
state.move_line_end();
assert_eq!(state.cursor, "first\nsecond".chars().count());
}
#[test]
fn test_delete_to_line_end_removes_text_after_cursor_on_current_line() {
let mut state = InputState::with_text("first\nsecond\nthird".to_string());
state.cursor = "first\nse".chars().count();
state.delete_to_line_end();
assert_eq!(state.text(), "first\nse\nthird");
assert_eq!(state.cursor, "first\nse".chars().count());
}
#[test]
fn test_delete_to_line_end_is_noop_at_newline() {
let mut state = InputState::with_text("first\nsecond".to_string());
state.cursor = "first".chars().count();
state.delete_to_line_end();
assert_eq!(state.text(), "first\nsecond");
assert_eq!(state.cursor, "first".chars().count());
}
#[test]
fn test_delete_to_line_end_clears_rest_of_single_line() {
let mut state = InputState::with_text("hello world".to_string());
state.cursor = "hello".chars().count();
state.delete_to_line_end();
assert_eq!(state.text(), "hello");
assert_eq!(state.cursor, "hello".chars().count());
}
}