Skip to main content

mermaid_cli/tui/state/
input.rs

1//! Input state management
2//!
3//! User input buffer, cursor handling, and input history navigation.
4
5use std::collections::VecDeque;
6
7/// Maximum entries kept in the input history deque. Matches the cap used
8/// by ConversationHistory::add_to_input_history so persisted and in-memory
9/// history stay in the same shape.
10const INPUT_HISTORY_CAP: usize = 100;
11
12/// Input state - user input buffer, cursor, and history
13///
14/// All cursor positions are byte offsets that are guaranteed to sit on
15/// UTF-8 char boundaries. Methods navigate by whole characters, not
16/// raw bytes, so multi-byte input (emoji, CJK, accented chars) is safe.
17pub struct InputBuffer {
18    /// User input buffer
19    pub content: String,
20    /// Cursor position as a **byte offset** (always on a char boundary)
21    pub cursor_position: usize,
22    /// Input history for arrow key navigation (persisted across sessions)
23    pub history: VecDeque<String>,
24    /// Current position in history (None = editing current input, Some(i) = viewing history[i])
25    pub history_index: Option<usize>,
26    /// Saved input when navigating away from current draft
27    pub history_buffer: String,
28}
29
30impl InputBuffer {
31    /// Create a new empty input buffer
32    pub fn new() -> Self {
33        Self {
34            content: String::new(),
35            cursor_position: 0,
36            history: VecDeque::new(),
37            history_index: None,
38            history_buffer: String::new(),
39        }
40    }
41
42    /// Load persisted input history (from a resumed conversation)
43    pub fn load_history(&mut self, history: VecDeque<String>) {
44        self.history = history;
45    }
46
47    /// Append an input to history with dedup + cap, mirroring
48    /// ConversationHistory::add_to_input_history.
49    ///
50    /// - Empty/whitespace inputs are ignored.
51    /// - Consecutive duplicates of the last entry are skipped.
52    /// - Oldest entry is evicted when the cap is reached.
53    pub fn add_to_history(&mut self, input: String) {
54        if input.trim().is_empty() {
55            return;
56        }
57        if let Some(last) = self.history.back()
58            && last == &input
59        {
60            return;
61        }
62        if self.history.len() >= INPUT_HISTORY_CAP {
63            self.history.pop_front();
64        }
65        self.history.push_back(input);
66    }
67
68    /// Clear the input buffer
69    pub fn clear(&mut self) {
70        self.content.clear();
71        self.cursor_position = 0;
72    }
73
74    /// Check if input is empty
75    pub fn is_empty(&self) -> bool {
76        self.content.is_empty()
77    }
78
79    /// Get the input content
80    pub fn get(&self) -> &str {
81        &self.content
82    }
83
84    /// Set the input content
85    pub fn set(&mut self, content: impl Into<String>) {
86        self.content = content.into();
87        self.cursor_position = self.content.len();
88    }
89
90    /// Insert a character at cursor position
91    pub fn insert(&mut self, c: char) {
92        self.content.insert(self.cursor_position, c);
93        self.cursor_position += c.len_utf8();
94    }
95
96    /// Insert a string at cursor position
97    pub fn insert_str(&mut self, s: &str) {
98        self.content.insert_str(self.cursor_position, s);
99        self.cursor_position += s.len();
100    }
101
102    /// Delete character before cursor (backspace)
103    pub fn backspace(&mut self) -> bool {
104        if self.cursor_position > 0 {
105            // Find the start of the previous character
106            let prev_boundary = self.content[..self.cursor_position]
107                .char_indices()
108                .next_back()
109                .map(|(idx, _)| idx)
110                .unwrap_or(0);
111            self.content.remove(prev_boundary);
112            self.cursor_position = prev_boundary;
113            true
114        } else {
115            false
116        }
117    }
118
119    /// Delete character at cursor (delete key)
120    pub fn delete(&mut self) -> bool {
121        if self.cursor_position < self.content.len() {
122            self.content.remove(self.cursor_position);
123            true
124        } else {
125            false
126        }
127    }
128
129    /// Move cursor left by one character
130    pub fn move_left(&mut self) {
131        if self.cursor_position > 0 {
132            // Find the previous char boundary
133            self.cursor_position = self.content[..self.cursor_position]
134                .char_indices()
135                .next_back()
136                .map(|(idx, _)| idx)
137                .unwrap_or(0);
138        }
139    }
140
141    /// Move cursor right by one character
142    pub fn move_right(&mut self) {
143        if self.cursor_position < self.content.len() {
144            // Find the next char boundary
145            self.cursor_position = self.content[self.cursor_position..]
146                .char_indices()
147                .nth(1)
148                .map(|(idx, _)| self.cursor_position + idx)
149                .unwrap_or(self.content.len());
150        }
151    }
152
153    /// Move cursor to start
154    pub fn move_home(&mut self) {
155        self.cursor_position = 0;
156    }
157
158    /// Move cursor to end
159    pub fn move_end(&mut self) {
160        self.cursor_position = self.content.len();
161    }
162}
163
164impl Default for InputBuffer {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn add_to_history_skips_empty_and_whitespace() {
176        let mut buf = InputBuffer::new();
177        buf.add_to_history(String::new());
178        buf.add_to_history("   ".into());
179        buf.add_to_history("\t\n".into());
180        assert!(buf.history.is_empty());
181    }
182
183    #[test]
184    fn add_to_history_dedups_consecutive() {
185        let mut buf = InputBuffer::new();
186        buf.add_to_history("hello".into());
187        buf.add_to_history("hello".into());
188        buf.add_to_history("world".into());
189        buf.add_to_history("hello".into()); // non-consecutive duplicate is kept
190        assert_eq!(
191            buf.history.iter().cloned().collect::<Vec<_>>(),
192            vec!["hello", "world", "hello"]
193        );
194    }
195
196    #[test]
197    fn add_to_history_caps_at_limit() {
198        let mut buf = InputBuffer::new();
199        for i in 0..(INPUT_HISTORY_CAP + 25) {
200            buf.add_to_history(format!("msg{}", i));
201        }
202        assert_eq!(buf.history.len(), INPUT_HISTORY_CAP);
203        // Oldest 25 evicted; first retained entry is msg25.
204        assert_eq!(buf.history.front().unwrap(), "msg25");
205        assert_eq!(
206            buf.history.back().unwrap(),
207            &format!("msg{}", INPUT_HISTORY_CAP + 24)
208        );
209    }
210}