use std::collections::VecDeque;
use std::ops::{Deref, DerefMut};
const HISTORY_CAP: usize = 100;
pub const MAX_COMPOSER_LEN: usize = 131_072;
#[derive(Debug, Clone, Default)]
pub struct ComposerEditor {
text: String,
cursor: usize,
}
impl ComposerEditor {
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
pub fn text(&self) -> &str {
&self.text
}
pub fn text_mut(&mut self) -> &mut String {
&mut self.text
}
pub fn cursor(&self) -> usize {
self.cursor
}
fn has_room_for(&self, additional_chars: usize) -> bool {
self.text.chars().count().saturating_add(additional_chars) <= MAX_COMPOSER_LEN
}
pub fn insert_char(&mut self, ch: char) {
if ch == '\r' {
return;
}
if !self.has_room_for(1) {
return;
}
self.text.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
pub fn insert_str(&mut self, s: &str) {
if s.is_empty() {
return;
}
let new_chars = s.chars().count();
if !self.has_room_for(new_chars) {
return;
}
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn delete_backward(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let prev = prev_char_boundary(&self.text, self.cursor);
self.text.drain(prev..self.cursor);
self.cursor = prev;
true
}
pub fn delete_forward(&mut self) -> bool {
if self.cursor >= self.text.len() {
return false;
}
let next = next_char_boundary(&self.text, self.cursor);
self.text.drain(self.cursor..next);
true
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = prev_char_boundary(&self.text, self.cursor);
}
}
pub fn move_right(&mut self) {
if self.cursor < self.text.len() {
self.cursor = next_char_boundary(&self.text, self.cursor);
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.text.len();
}
pub fn on_first_line(&self) -> bool {
!self.text[..self.cursor].contains('\n')
}
pub fn on_last_line(&self) -> bool {
!self.text[self.cursor..].contains('\n')
}
pub fn move_up_line(&mut self) -> bool {
let before = &self.text[..self.cursor];
let Some(cur_nl) = before.rfind('\n') else {
return false;
};
let cur_line_start = cur_nl + 1;
let col = self.cursor - cur_line_start;
let prev_line_end = cur_nl;
let prev_line_start = self.text[..prev_line_end]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let prev_line_len = prev_line_end - prev_line_start;
let mut new = prev_line_start + col.min(prev_line_len);
while new > prev_line_start && !self.text.is_char_boundary(new) {
new -= 1;
}
self.cursor = new;
true
}
pub fn move_down_line(&mut self) -> bool {
let rest = &self.text[self.cursor..];
let Some(nl_rel) = rest.find('\n') else {
return false;
};
let next_line_start = self.cursor + nl_rel + 1;
let cur_line_start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let col = self.cursor - cur_line_start;
let next_line_end = self.text[next_line_start..]
.find('\n')
.map(|i| next_line_start + i)
.unwrap_or(self.text.len());
let next_line_len = next_line_end - next_line_start;
let mut new = next_line_start + col.min(next_line_len);
while new > next_line_start && !self.text.is_char_boundary(new) {
new -= 1;
}
self.cursor = new;
true
}
pub fn move_word_left(&mut self) {
if self.cursor == 0 {
return;
}
let before = &self.text[..self.cursor];
let trimmed = before.trim_end();
if trimmed.len() == before.len() {
if let Some(i) = trimmed
.char_indices()
.rev()
.skip_while(|(_, c)| c.is_whitespace())
.find(|(_, c)| c.is_whitespace())
.map(|(i, _)| i + 1)
{
self.cursor = i;
} else {
self.cursor = 0;
}
} else {
self.cursor = trimmed.len();
}
}
pub fn delete_word_backward(&mut self) {
if self.cursor == 0 {
return;
}
let start = {
let before = &self.text[..self.cursor];
let trimmed = before.trim_end();
if trimmed.len() == before.len() {
trimmed
.char_indices()
.rev()
.skip_while(|(_, c)| c.is_whitespace())
.find(|(_, c)| c.is_whitespace())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0)
} else {
trimmed.len()
}
};
self.text.drain(start..self.cursor);
self.cursor = start;
}
pub fn delete_to_start(&mut self) {
self.text.drain(0..self.cursor);
self.cursor = 0;
}
pub fn set_text(&mut self, text: String) {
self.cursor = text.len();
self.text = text;
}
}
impl Deref for ComposerEditor {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.text
}
}
impl DerefMut for ComposerEditor {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.text
}
}
fn prev_char_boundary(s: &str, pos: usize) -> usize {
s[..pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
}
fn next_char_boundary(s: &str, pos: usize) -> usize {
s[pos..]
.char_indices()
.nth(1)
.map(|(i, _)| pos + i)
.unwrap_or_else(|| s.len())
}
#[derive(Debug, Clone, Default)]
pub struct PromptHistory {
entries: VecDeque<String>,
browse: Option<usize>,
draft: Option<String>,
}
impl PromptHistory {
pub fn push_sent(&mut self, prompt: &str) {
let prompt = prompt.trim();
if prompt.is_empty() {
return;
}
if self.entries.back().is_some_and(|last| last == prompt) {
return;
}
self.entries.push_back(prompt.to_string());
while self.entries.len() > HISTORY_CAP {
self.entries.pop_front();
}
self.browse = None;
self.draft = None;
}
pub fn browsing(&self) -> bool {
self.browse.is_some()
}
pub fn browse_up(&mut self, current: &mut ComposerEditor) -> bool {
if self.entries.is_empty() {
return false;
}
if self.browse.is_none() {
self.draft = Some(current.text().to_string());
}
let idx = self.browse.unwrap_or(self.entries.len()).saturating_sub(1);
if idx >= self.entries.len() {
return false;
}
self.browse = Some(idx);
current.set_text(self.entries[idx].clone());
true
}
pub fn browse_down(&mut self, current: &mut ComposerEditor) -> bool {
let Some(idx) = self.browse else {
return false;
};
if idx + 1 >= self.entries.len() {
self.browse = None;
if let Some(draft) = self.draft.take() {
current.set_text(draft);
} else {
current.clear();
}
return true;
}
let next = idx + 1;
self.browse = Some(next);
current.set_text(self.entries[next].clone());
true
}
pub fn reset_browse(&mut self) {
self.browse = None;
self.draft = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_delete_at_cursor() {
let mut ed = ComposerEditor::default();
ed.insert_char('a');
ed.insert_char('b');
ed.move_left();
ed.insert_char('X');
assert_eq!(ed.text(), "aXb");
assert!(ed.delete_forward());
assert_eq!(ed.text(), "aX");
}
#[test]
fn move_up_down_multiline() {
let mut ed = ComposerEditor::default();
ed.insert_str("hello\nworld\nfoo");
assert!(ed.on_last_line());
assert!(!ed.on_first_line());
assert!(ed.move_up_line()); assert_eq!(&ed.text()[..ed.cursor()], "hello\nwor");
assert!(ed.move_up_line()); assert_eq!(&ed.text()[..ed.cursor()], "hel");
assert!(ed.on_first_line());
assert!(!ed.move_up_line());
assert!(ed.move_down_line()); assert_eq!(&ed.text()[..ed.cursor()], "hello\nwor");
assert!(ed.move_down_line()); assert!(ed.on_last_line());
assert!(!ed.move_down_line()); }
#[test]
fn move_up_clamps_short_line() {
let mut ed = ComposerEditor::default();
ed.insert_str("hi\nworld");
assert!(ed.move_up_line());
assert_eq!(&ed.text()[..ed.cursor()], "hi"); }
#[test]
fn history_browse_restores_draft() {
let mut h = PromptHistory::default();
h.push_sent("first");
h.push_sent("second");
let mut ed = ComposerEditor::default();
ed.insert_str("draft");
assert!(h.browse_up(&mut ed));
assert_eq!(ed.text(), "second");
assert!(h.browse_down(&mut ed));
assert_eq!(ed.text(), "draft");
}
#[test]
fn insert_stops_at_max_len() {
let mut ed = ComposerEditor::default();
ed.insert_str(&"x".repeat(MAX_COMPOSER_LEN));
assert_eq!(ed.text().chars().count(), MAX_COMPOSER_LEN);
ed.insert_char('y');
assert_eq!(ed.text().chars().count(), MAX_COMPOSER_LEN);
}
}