use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::ops::Range;
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Debug)]
pub struct Composer {
text: String,
cursor: usize,
preferred_column: Option<usize>,
visual_line_hint: Option<usize>,
history: Vec<String>,
history_cursor: Option<usize>,
draft: String,
placeholder: String,
selection_anchor: Option<usize>,
}
impl Composer {
pub fn new(placeholder: impl Into<String>) -> Self {
Self {
text: String::new(),
cursor: 0,
preferred_column: None,
visual_line_hint: None,
history: Vec::new(),
history_cursor: None,
draft: String::new(),
placeholder: placeholder.into(),
selection_anchor: None,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
self.draft.clear();
self.selection_anchor = None;
}
pub fn set_text(&mut self, text: String) {
self.text = text;
self.cursor = self.text.len();
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
self.selection_anchor = None;
}
pub fn remember_submission(&mut self, submission: &str) {
if submission.trim().is_empty() {
self.history_cursor = None;
self.draft.clear();
return;
}
if self
.history
.last()
.is_none_or(|previous| previous != submission)
{
self.history.push(submission.to_string());
}
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
self.draft.clear();
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
self.handle_key_with_history(key, true)
}
pub fn handle_key_with_history(
&mut self,
key: KeyEvent,
record_history: bool,
) -> Option<String> {
let allow_history_navigation = record_history;
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return None;
}
match key.code {
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => match c {
'a' => {
self.select_all();
}
'e' => {
self.cursor = self.text.len();
self.preferred_column = None;
self.selection_anchor = None;
}
'j' => {
self.insert_char('\n');
}
'u' => {
self.text.clear();
self.cursor = 0;
self.preferred_column = None;
self.selection_anchor = None;
}
'k' => {
self.text.truncate(self.cursor);
self.preferred_column = None;
self.selection_anchor = None;
}
'n' if allow_history_navigation => {
self.select_next_history();
}
_ => {}
},
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::SUPER) => {
self.select_all();
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::SUPER) => {
self.cursor = self.text.len();
self.preferred_column = None;
self.selection_anchor = None;
}
KeyCode::Char(c) => {
self.insert_char(c);
}
KeyCode::Enter => {
if key.modifiers.contains(KeyModifiers::SHIFT)
|| key.modifiers.contains(KeyModifiers::ALT)
{
self.insert_char('\n');
} else {
let submission = self.text.trim_end().to_string();
if submission.is_empty() {
return None;
}
if record_history {
self.remember_submission(&submission);
}
self.clear();
return Some(submission);
}
}
KeyCode::Backspace => {
if key.modifiers.contains(KeyModifiers::SUPER) {
self.delete_to_line_start();
} else if key.modifiers.contains(KeyModifiers::ALT)
|| key.modifiers.contains(KeyModifiers::CONTROL)
{
self.delete_previous_word();
} else {
self.delete_previous_char();
}
}
KeyCode::Delete => {
if key.modifiers.contains(KeyModifiers::SUPER) {
self.delete_to_line_start();
} else if key.modifiers.contains(KeyModifiers::ALT)
|| key.modifiers.contains(KeyModifiers::CONTROL)
{
self.delete_previous_word();
} else {
self.delete_next_char();
}
}
KeyCode::Left => {
self.move_left();
}
KeyCode::Right => {
self.move_right();
}
KeyCode::Home => {
self.cursor = self.line_start(self.cursor);
self.preferred_column = None;
self.selection_anchor = None;
}
KeyCode::End => {
self.cursor = self.line_end(self.cursor);
self.preferred_column = None;
self.selection_anchor = None;
}
KeyCode::Tab => {
self.insert_str(" ");
}
_ => {}
}
None
}
pub fn preferred_height(&self, width: u16, max_lines: u16) -> u16 {
let mut visible_lines = display_line_count(&self.text, width as usize) as u16;
if self.cursor_wraps_to_next_row(width as usize) {
visible_lines = visible_lines.saturating_add(1);
}
visible_lines.min(max_lines).saturating_add(2)
}
pub fn cursor_position(&self, width: u16) -> (u16, u16) {
let width = width as usize;
if width == 0 {
return (0, 0);
}
let lines = visual_lines(&self.text, width);
let cursor = self.cursor.min(self.text.len());
let hinted_line = self.visual_line_hint.and_then(|line_index| {
lines.get(line_index).and_then(|line| {
if cursor >= line.start && cursor <= line.end {
Some(line_index)
} else {
None
}
})
});
let line_index = hinted_line
.or_else(|| {
lines
.iter()
.enumerate()
.rposition(|(_, line)| line.start <= cursor)
})
.unwrap_or(0);
let line = lines[line_index];
let column = display_width(&self.text[line.start..cursor]);
(line_index as u16, column as u16)
}
pub fn move_up(&mut self, width: u16) {
self.move_vertical(width, -1);
}
pub fn move_down(&mut self, width: u16) {
self.move_vertical(width, 1);
}
pub fn set_cursor_at_visual_position(&mut self, width: u16, line: u16, column: u16) {
let width = width as usize;
if width == 0 {
return;
}
let lines = visual_lines(&self.text, width);
if lines.is_empty() {
self.cursor = 0;
self.preferred_column = Some(column as usize);
return;
}
let line_index = line.min(lines.len().saturating_sub(1) as u16) as usize;
let line = lines[line_index];
let column = column as usize;
self.cursor = cursor_from_visual_position(&self.text, line, column);
self.preferred_column = Some(column);
self.visual_line_hint = Some(line_index);
}
pub fn display_line_count(&self, width: usize) -> usize {
display_line_count(&self.text, width)
}
pub fn cursor_wraps_to_next_row(&self, width: usize) -> bool {
if width == 0 || self.cursor != self.text.len() {
return false;
}
visual_lines(&self.text, width)
.last()
.is_some_and(|line| line.width == width)
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn has_selection(&self) -> bool {
self.selection_anchor
.is_some_and(|anchor| anchor != self.cursor)
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
self.selection_anchor.and_then(|anchor| {
if anchor == self.cursor {
None
} else {
let start = anchor.min(self.cursor);
let end = anchor.max(self.cursor);
Some((start, end))
}
})
}
pub fn selected_text(&self) -> Option<&str> {
self.selection_range()
.map(|(start, end)| &self.text[start..end])
}
pub fn select_all(&mut self) {
if !self.text.is_empty() {
self.selection_anchor = Some(0);
self.cursor = self.text.len();
self.preferred_column = None;
self.visual_line_hint = None;
}
}
pub fn clear_selection(&mut self) {
self.selection_anchor = None;
}
pub fn start_selection(&mut self) {
self.selection_anchor = Some(self.cursor);
}
pub fn set_selection(&mut self, start: usize, end: usize) {
let start = start.min(self.text.len());
let end = end.min(self.text.len()).max(start);
self.selection_anchor = Some(start);
self.cursor = end;
self.preferred_column = None;
self.visual_line_hint = None;
}
fn delete_selection(&mut self) -> bool {
if let Some((start, end)) = self.selection_range() {
self.text.drain(start..end);
self.cursor = start;
self.selection_anchor = None;
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
true
} else {
false
}
}
pub fn visual_lines(&self, width: usize) -> Vec<Range<usize>> {
visual_lines(&self.text, width)
.into_iter()
.map(|l| l.start..l.end)
.collect()
}
fn insert_char(&mut self, ch: char) {
self.delete_selection();
self.text.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
pub fn insert_str(&mut self, value: &str) {
self.delete_selection();
self.text.insert_str(self.cursor, value);
self.cursor += value.len();
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
pub fn replace_range(&mut self, start: usize, end: usize, replacement: &str) {
let start = start.min(self.text.len());
let end = end.min(self.text.len()).max(start);
self.text.replace_range(start..end, replacement);
self.cursor = start + replacement.len();
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
fn delete_previous_char(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor == 0 {
return;
}
let previous = self.previous_char_boundary(self.cursor);
self.text.drain(previous..self.cursor);
self.cursor = previous;
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
fn delete_previous_word(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor == 0 {
return;
}
let mut boundary = self.cursor;
while boundary > 0 {
let previous = self.previous_char_boundary(boundary);
let ch = self.text[previous..boundary].chars().next().unwrap();
if !ch.is_whitespace() {
break;
}
boundary = previous;
}
while boundary > 0 {
let previous = self.previous_char_boundary(boundary);
let ch = self.text[previous..boundary].chars().next().unwrap();
if ch.is_whitespace() {
break;
}
boundary = previous;
}
self.text.drain(boundary..self.cursor);
self.cursor = boundary;
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
fn delete_to_line_start(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor == 0 {
return;
}
let start = self.line_start(self.cursor);
self.text.drain(start..self.cursor);
self.cursor = start;
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
fn delete_next_char(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor >= self.text.len() {
return;
}
let next = self.next_char_boundary(self.cursor);
self.text.drain(self.cursor..next);
self.preferred_column = None;
self.visual_line_hint = None;
self.history_cursor = None;
}
fn move_left(&mut self) {
self.cursor = self.previous_char_boundary(self.cursor);
self.preferred_column = None;
self.visual_line_hint = None;
self.selection_anchor = None; }
fn move_right(&mut self) {
self.cursor = self.next_char_boundary(self.cursor);
self.preferred_column = None;
self.visual_line_hint = None;
self.selection_anchor = None; }
fn select_next_history(&mut self) {
let Some(index) = self.history_cursor else {
return;
};
if index + 1 < self.history.len() {
self.history_cursor = Some(index + 1);
self.text = self.history[index + 1].clone();
} else {
self.history_cursor = None;
self.text = self.draft.clone();
}
self.cursor = self.text.len();
self.preferred_column = None;
self.visual_line_hint = None;
}
fn previous_char_boundary(&self, index: usize) -> usize {
if index == 0 {
return 0;
}
self.text
.char_indices()
.take_while(|(byte_index, _)| *byte_index < index)
.map(|(byte_index, _)| byte_index)
.last()
.unwrap_or(0)
}
fn next_char_boundary(&self, index: usize) -> usize {
if index >= self.text.len() {
return self.text.len();
}
self.text[index..]
.char_indices()
.nth(1)
.map(|(relative_index, _)| index + relative_index)
.unwrap_or(self.text.len())
}
fn line_start(&self, index: usize) -> usize {
self.text[..index]
.rfind('\n')
.map(|position| position + 1)
.unwrap_or(0)
}
fn line_end(&self, index: usize) -> usize {
self.text[index..]
.find('\n')
.map(|position| index + position)
.unwrap_or(self.text.len())
}
fn move_vertical(&mut self, width: u16, delta: isize) {
let width = width as usize;
if width == 0 || delta == 0 {
return;
}
let lines = visual_lines(&self.text, width);
let (current_line, current_column) = self.cursor_position(width as u16);
let desired_column = self.preferred_column.unwrap_or(current_column as usize);
let last_line = lines.len().saturating_sub(1) as isize;
let target_line = (current_line as isize + delta).clamp(0, last_line) as usize;
if target_line == current_line as usize {
self.preferred_column = Some(desired_column);
self.visual_line_hint = Some(target_line);
return;
}
self.cursor = cursor_from_visual_position(&self.text, lines[target_line], desired_column);
self.preferred_column = Some(desired_column);
self.visual_line_hint = Some(target_line);
self.selection_anchor = None; }
}
fn display_line_count(text: &str, width: usize) -> usize {
if width == 0 {
return text.split('\n').count().max(1);
}
visual_lines(text, width).len().max(1)
}
#[derive(Clone, Copy, Debug)]
struct VisualLine {
start: usize,
end: usize,
width: usize,
}
fn visual_lines(text: &str, width: usize) -> Vec<VisualLine> {
if width == 0 {
return vec![VisualLine {
start: 0,
end: text.len(),
width: 0,
}];
}
let mut lines = Vec::new();
let mut line_start = 0usize;
let mut current_width = 0usize;
for (byte_index, ch) in text.char_indices() {
if ch == '\n' {
lines.push(VisualLine {
start: line_start,
end: byte_index,
width: current_width,
});
line_start = byte_index + ch.len_utf8();
current_width = 0;
continue;
}
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width > 0 && current_width + char_width > width {
lines.push(VisualLine {
start: line_start,
end: byte_index,
width: current_width,
});
line_start = byte_index;
current_width = 0;
}
current_width += char_width;
}
lines.push(VisualLine {
start: line_start,
end: text.len(),
width: current_width,
});
lines
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
fn cursor_from_visual_position(text: &str, line: VisualLine, target_column: usize) -> usize {
let mut current_column = 0usize;
let mut cursor = line.start;
for (relative_index, ch) in text[line.start..line.end].char_indices() {
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_cursor = line.start + relative_index + ch.len_utf8();
if char_width == 0 {
cursor = next_cursor;
continue;
}
if target_column <= current_column {
return cursor;
}
if target_column < current_column + char_width {
if (target_column - current_column) * 2 < char_width {
return cursor;
}
return next_cursor;
}
current_column += char_width;
cursor = next_cursor;
}
let _ = line.width;
line.end
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctrl_j_inserts_newline() {
let mut composer = Composer::new("placeholder");
let result = composer.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(composer.text(), "\n");
}
#[test]
fn preferred_height_counts_trailing_newline() {
let mut composer = Composer::new("placeholder");
composer.set_text("hello\n".to_string());
assert_eq!(composer.preferred_height(10, 10), 4);
}
#[test]
fn preferred_height_wraps_long_lines() {
let mut composer = Composer::new("placeholder");
composer.set_text("abcdefghij".to_string());
assert_eq!(composer.preferred_height(4, 10), 5);
}
#[test]
fn preferred_height_adds_cursor_row_for_full_width_trailing_line() {
let mut composer = Composer::new("placeholder");
composer.set_text("abcd".to_string());
assert_eq!(composer.preferred_height(4, 10), 4);
assert!(composer.cursor_wraps_to_next_row(4));
}
#[test]
fn cursor_position_wraps_long_lines() {
let mut composer = Composer::new("placeholder");
composer.set_text("abcdefg".to_string());
composer.replace_range(0, 7, "abcdefg");
composer.cursor = 7;
assert_eq!(composer.cursor_position(4), (1, 3));
}
#[test]
fn cursor_position_tracks_mixed_width_wraps() {
let mut composer = Composer::new("placeholder");
composer.set_text("ab中文cd".to_string());
composer.cursor = composer.text().len();
assert_eq!(composer.cursor_position(4), (1, 4));
}
#[test]
fn vertical_movement_preserves_visual_column() {
let mut composer = Composer::new("placeholder");
composer.set_text("ab中文cd".to_string());
composer.cursor = composer.text().len();
composer.move_up(4);
assert_eq!(composer.cursor_position(4), (0, 4));
composer.move_down(4);
assert_eq!(composer.cursor_position(4), (1, 4));
}
#[test]
fn visual_position_mapping_follows_wrapped_lines() {
let mut composer = Composer::new("placeholder");
composer.set_text("ab中文cd".to_string());
composer.set_cursor_at_visual_position(4, 0, 2);
assert_eq!(composer.cursor_position(4), (0, 2));
composer.set_cursor_at_visual_position(4, 1, 4);
assert_eq!(composer.cursor_position(4), (1, 4));
}
#[test]
fn ctrl_backspace_deletes_previous_word() {
let mut composer = Composer::new("placeholder");
composer.set_text("hello world".to_string());
composer.cursor = composer.text().len();
let result = composer.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(composer.text(), "hello ");
assert_eq!(composer.cursor(), 6);
}
#[test]
fn alt_backspace_deletes_previous_word() {
let mut composer = Composer::new("placeholder");
composer.set_text("hello world".to_string());
composer.cursor = composer.text().len();
let result = composer.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
assert!(result.is_none());
assert_eq!(composer.text(), "hello ");
assert_eq!(composer.cursor(), 6);
}
#[test]
fn super_backspace_deletes_to_line_start() {
let mut composer = Composer::new("placeholder");
composer.set_text("hello world".to_string());
composer.cursor = composer.text().len();
let result = composer.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER));
assert!(result.is_none());
assert_eq!(composer.text(), "");
assert_eq!(composer.cursor(), 0);
}
#[test]
fn ctrl_backspace_skips_trailing_whitespace() {
let mut composer = Composer::new("placeholder");
composer.set_text("hello world ".to_string());
composer.cursor = composer.text().len();
let result = composer.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(composer.text(), "hello ");
assert_eq!(composer.cursor(), 6);
}
}