use std::time::Instant;
use super::super::types::ContentPart;
use super::mouse_selection::MouseSelectionState;
#[derive(Clone, Debug)]
pub struct InputHistoryEntry {
content: String,
elements: Vec<ContentPart>,
}
impl InputHistoryEntry {
pub fn from_content_and_attachments(content: String, attachments: Vec<ContentPart>) -> Self {
let mut elements = Vec::new();
if !content.is_empty() {
elements.push(ContentPart::text(content.clone()));
}
elements.extend(attachments.into_iter().filter(ContentPart::is_image));
Self { content, elements }
}
pub fn content(&self) -> &str {
&self.content
}
pub fn has_attachments(&self) -> bool {
self.elements.iter().any(|part| part.is_image())
}
pub fn is_empty(&self) -> bool {
self.content.trim().is_empty() && !self.has_attachments()
}
pub fn attachment_elements(&self) -> Vec<ContentPart> {
self.elements
.iter()
.filter(|part| part.is_image())
.cloned()
.collect()
}
}
#[derive(Clone, Debug)]
pub struct InputManager {
content: String,
cursor: usize,
selection_anchor: Option<usize>,
selection_copied: bool,
attachments: Vec<ContentPart>,
history: Vec<InputHistoryEntry>,
history_index: Option<usize>,
history_draft: Option<InputHistoryEntry>,
last_escape_time: Option<Instant>,
}
#[allow(dead_code)]
impl InputManager {
pub fn new() -> Self {
Self {
content: String::new(),
cursor: 0,
selection_anchor: None,
selection_copied: false,
attachments: Vec::new(),
history: Vec::new(),
history_index: None,
history_draft: None,
last_escape_time: None,
}
}
pub fn content(&self) -> &str {
&self.content
}
pub fn set_content(&mut self, content: String) {
self.content = content.clone();
self.cursor = content.len();
self.clear_selection();
self.reset_history_navigation();
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn set_cursor(&mut self, pos: usize) {
self.cursor = pos.min(self.content.len());
self.clear_selection();
}
pub fn set_cursor_with_selection(&mut self, pos: usize) {
let previous_selection = self.selection_range();
let next = pos.min(self.content.len());
if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor);
}
self.cursor = next;
if self.selection_range() != previous_selection {
self.selection_copied = false;
}
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
let anchor = self.selection_anchor?;
if anchor == self.cursor {
return None;
}
Some((anchor.min(self.cursor), anchor.max(self.cursor)))
}
pub fn has_selection(&self) -> bool {
self.selection_range().is_some()
}
pub fn selected_text(&self) -> Option<&str> {
let (start, end) = self.selection_range()?;
Some(&self.content[start..end])
}
pub fn copy_selected_text_to_clipboard(&mut self) -> bool {
let Some(text) = self.selected_text() else {
return false;
};
MouseSelectionState::copy_to_clipboard(text);
self.selection_copied = true;
true
}
pub fn selection_needs_copy(&self) -> bool {
self.has_selection() && !self.selection_copied
}
pub fn clear_selection(&mut self) {
self.selection_anchor = None;
self.selection_copied = false;
}
fn replace_range(&mut self, start: usize, end: usize, replacement: &str) {
self.content.replace_range(start..end, replacement);
self.cursor = start + replacement.len();
self.clear_selection();
}
pub fn delete_selection(&mut self) -> bool {
let Some((start, end)) = self.selection_range() else {
return false;
};
self.replace_range(start, end, "");
true
}
pub fn move_cursor_left(&mut self) {
if let Some((start, _)) = self.selection_range() {
self.cursor = start;
self.clear_selection();
return;
}
if self.cursor > 0 {
let mut pos = self.cursor - 1;
while pos > 0 && !self.content.is_char_boundary(pos) {
pos -= 1;
}
self.cursor = pos;
}
}
pub fn move_cursor_right(&mut self) {
if let Some((_, end)) = self.selection_range() {
self.cursor = end;
self.clear_selection();
return;
}
if self.cursor < self.content.len() {
let mut pos = self.cursor + 1;
while pos < self.content.len() && !self.content.is_char_boundary(pos) {
pos += 1;
}
self.cursor = pos;
}
}
pub fn move_cursor_to_start(&mut self) {
self.cursor = 0;
self.clear_selection();
}
pub fn move_cursor_to_end(&mut self) {
self.cursor = self.content.len();
self.clear_selection();
}
pub fn insert_char(&mut self, ch: char) {
let mut buf = [0_u8; 4];
self.insert_text(ch.encode_utf8(&mut buf));
}
pub fn insert_text(&mut self, text: &str) {
if let Some((start, end)) = self.selection_range() {
self.replace_range(start, end, text);
} else {
self.content.insert_str(self.cursor, text);
self.cursor += text.len();
self.clear_selection();
}
}
pub fn backspace(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor > 0 {
let mut pos = self.cursor - 1;
while pos > 0 && !self.content.is_char_boundary(pos) {
pos -= 1;
}
self.content.drain(pos..self.cursor);
self.cursor = pos;
}
}
pub fn delete(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor < self.content.len() {
let mut end = self.cursor + 1;
while end < self.content.len() && !self.content.is_char_boundary(end) {
end += 1;
}
self.content.drain(self.cursor..end);
}
}
pub fn delete_word_forward(&mut self) {
if self.delete_selection() {
return;
}
if self.cursor >= self.content.len() {
return;
}
let rest = &self.content[self.cursor..];
let end_offset = rest
.char_indices()
.skip_while(|(_, c)| !c.is_alphanumeric())
.skip_while(|(_, c)| c.is_alphanumeric())
.map(|(i, _)| i)
.next()
.unwrap_or(rest.len());
self.content.drain(self.cursor..self.cursor + end_offset);
}
pub fn clear(&mut self) {
self.content.clear();
self.cursor = 0;
self.clear_selection();
self.attachments.clear();
self.reset_history_navigation();
}
pub fn add_to_history(&mut self, entry: InputHistoryEntry) {
if !entry.is_empty() {
if let Some(last) = self.history.last()
&& last.content == entry.content
&& last.elements == entry.elements
{
self.reset_history_navigation();
return;
}
self.history.push(entry);
}
self.reset_history_navigation();
}
pub fn go_to_next_history(&mut self) -> Option<InputHistoryEntry> {
match self.history_index {
None => None,
Some(0) => {
self.history_index = None;
self.history_draft.take()
}
Some(i) => {
self.history_index = Some(i - 1);
self.history.get(i - 1).cloned()
}
}
}
pub fn go_to_previous_history(&mut self) -> Option<InputHistoryEntry> {
let current_index = match self.history_index {
None => {
self.history_draft = Some(self.current_history_entry());
self.history.len().saturating_sub(1)
}
Some(i) => {
if i == 0 {
return None;
}
i - 1
}
};
if current_index < self.history.len() {
self.history_index = Some(current_index);
self.history.get(current_index).cloned()
} else {
None
}
}
pub fn reset_history_navigation(&mut self) {
self.history_index = None;
self.history_draft = None;
}
pub fn check_escape_double_tap(&mut self) -> bool {
let now = Instant::now();
let is_double_tap = if let Some(last_time) = self.last_escape_time {
now.duration_since(last_time).as_millis() < 300
} else {
false
};
self.last_escape_time = Some(now);
is_double_tap
}
pub fn history(&self) -> &[InputHistoryEntry] {
&self.history
}
pub fn history_texts(&self) -> Vec<String> {
self.history
.iter()
.map(|entry| entry.content.clone())
.collect()
}
pub fn history_index(&self) -> Option<usize> {
self.history_index
}
pub fn attachments(&self) -> &[ContentPart] {
&self.attachments
}
pub fn set_attachments(&mut self, attachments: Vec<ContentPart>) {
self.attachments = attachments
.into_iter()
.filter(ContentPart::is_image)
.collect();
}
pub fn current_history_entry(&self) -> InputHistoryEntry {
InputHistoryEntry::from_content_and_attachments(
self.content.clone(),
self.attachments.clone(),
)
}
pub fn apply_history_entry(&mut self, entry: InputHistoryEntry) {
self.content = entry.content.clone();
self.cursor = self.content.len();
self.clear_selection();
self.attachments = entry.attachment_elements();
}
pub fn apply_history_index(&mut self, index: usize) -> bool {
let Some(entry) = self.history.get(index).cloned() else {
return false;
};
self.apply_history_entry(entry);
true
}
}
impl Default for InputManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_input_manager_is_empty() {
let manager = InputManager::new();
assert_eq!(manager.content(), "");
assert_eq!(manager.cursor(), 0);
}
#[test]
fn insert_text_updates_content_and_cursor() {
let mut manager = InputManager::new();
manager.insert_text("hello");
assert_eq!(manager.content(), "hello");
assert_eq!(manager.cursor(), 5);
}
#[test]
fn backspace_removes_character_before_cursor() {
let mut manager = InputManager::new();
manager.insert_text("hello");
manager.backspace();
assert_eq!(manager.content(), "hell");
assert_eq!(manager.cursor(), 4);
}
#[test]
fn delete_removes_character_at_cursor() {
let mut manager = InputManager::new();
manager.insert_text("hello");
manager.set_cursor(1);
manager.delete();
assert_eq!(manager.content(), "hllo");
}
#[test]
fn move_cursor_left_and_right() {
let mut manager = InputManager::new();
manager.insert_text("hello");
manager.move_cursor_left();
assert_eq!(manager.cursor(), 4);
manager.move_cursor_right();
assert_eq!(manager.cursor(), 5);
}
#[test]
fn clear_resets_state() {
let mut manager = InputManager::new();
manager.insert_text("hello");
manager.clear();
assert_eq!(manager.content(), "");
assert_eq!(manager.cursor(), 0);
}
#[test]
fn history_navigation() {
let mut manager = InputManager::new();
manager.add_to_history(InputHistoryEntry::from_content_and_attachments(
"first".to_owned(),
Vec::new(),
));
manager.add_to_history(InputHistoryEntry::from_content_and_attachments(
"second".to_owned(),
Vec::new(),
));
assert_eq!(
manager
.go_to_previous_history()
.map(|entry| entry.content.clone()),
Some("second".to_owned())
);
assert_eq!(
manager
.go_to_previous_history()
.map(|entry| entry.content.clone()),
Some("first".to_owned())
);
assert_eq!(
manager
.go_to_previous_history()
.map(|entry| entry.content.clone()),
None
);
}
#[test]
fn history_navigation_saves_draft() {
let mut manager = InputManager::new();
manager.set_content("current".to_owned());
manager.add_to_history(InputHistoryEntry::from_content_and_attachments(
"previous".to_owned(),
Vec::new(),
));
manager.go_to_previous_history();
assert_eq!(
manager
.go_to_next_history()
.map(|entry| entry.content.clone()),
Some("current".to_owned())
);
}
#[test]
fn escape_double_tap_detection() {
let mut manager = InputManager::new();
assert!(!manager.check_escape_double_tap());
}
#[test]
fn utf8_cursor_movement() {
let mut manager = InputManager::new();
manager.insert_text("你好");
assert_eq!(manager.cursor(), 6);
manager.move_cursor_left();
assert_eq!(manager.cursor(), 3);
manager.move_cursor_right();
assert_eq!(manager.cursor(), 6);
}
#[test]
fn history_navigation_restores_attachments() {
let mut manager = InputManager::new();
manager.set_content("check this".to_owned());
manager.set_attachments(vec![ContentPart::image(
"encoded".to_owned(),
"image/png".to_owned(),
)]);
manager.add_to_history(manager.current_history_entry());
manager.clear();
let entry = manager.go_to_previous_history().expect("history entry");
manager.apply_history_entry(entry);
assert_eq!(manager.content(), "check this");
assert_eq!(manager.attachments().len(), 1);
}
#[test]
fn insert_text_replaces_selection() {
let mut manager = InputManager::new();
manager.insert_text("hello world");
manager.set_cursor(5);
manager.set_cursor_with_selection(11);
manager.insert_text(" there");
assert_eq!(manager.content(), "hello there");
assert_eq!(manager.cursor(), "hello there".len());
assert!(!manager.has_selection());
}
#[test]
fn backspace_deletes_selected_range() {
let mut manager = InputManager::new();
manager.insert_text("hello world");
manager.set_cursor(0);
manager.set_cursor_with_selection(5);
manager.backspace();
assert_eq!(manager.content(), " world");
assert_eq!(manager.cursor(), 0);
assert!(!manager.has_selection());
}
#[test]
fn move_cursor_left_collapses_selection_to_start() {
let mut manager = InputManager::new();
manager.insert_text("hello world");
manager.set_cursor(0);
manager.set_cursor_with_selection(5);
manager.move_cursor_left();
assert_eq!(manager.cursor(), 0);
assert!(!manager.has_selection());
}
}