Skip to main content

stynx_code_tui/state/
input_state.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum InputMode {
3    Insert,
4    Normal,
5}
6
7pub struct InputState {
8    pub buffer: String,
9    pub cursor_pos: usize,
10    pub mode: InputMode,
11    pub history: Vec<String>,
12    pub history_index: Option<usize>,
13    pub suggestion: String,
14    /// Most recent large paste; expanded back into the message on submit.
15    pub pasted_buffer: Option<String>,
16    /// File paths for images pasted via Ctrl+V, in the order they were pasted.
17    /// In the buffer they appear as `[Image #1]`, `[Image #2]`, … and are
18    /// expanded back to `@<path>` tokens at submit time.
19    pub pasted_images: Vec<std::path::PathBuf>,
20    /// (command, description) pairs matching the slash prefix in the buffer.
21    pub slash_matches: Vec<(String, String)>,
22    pub slash_selected: usize,
23}
24
25impl InputState {
26    pub fn new() -> Self {
27        Self {
28            buffer: String::new(),
29            cursor_pos: 0,
30            mode: InputMode::Insert,
31            history: Vec::new(),
32            history_index: None,
33            suggestion: String::new(),
34            pasted_buffer: None,
35            pasted_images: Vec::new(),
36            slash_matches: Vec::new(),
37            slash_selected: 0,
38        }
39    }
40
41    /// Register a pasted image, insert its `[Image #N]` placeholder into the
42    /// buffer at the cursor, and return the assigned index.
43    pub fn insert_image_paste(&mut self, path: std::path::PathBuf) -> usize {
44        self.pasted_images.push(path);
45        let idx = self.pasted_images.len();
46        let token = format!("[Image #{idx}]");
47        for c in token.chars() {
48            self.insert_char(c);
49        }
50        idx
51    }
52
53    /// Expand the buffer for submission:
54    /// - `[Pasted N lines, M chars]` → the real pasted text
55    /// - `[Image #N]`                 → `@<path>` so file-reference expansion
56    ///                                  picks the image up downstream
57    pub fn expand_for_submit(&self) -> String {
58        let mut out = self.buffer.clone();
59        if let Some(real) = &self.pasted_buffer {
60            if let Some(start) = out.find("[Pasted ") {
61                if let Some(rel_end) = out[start..].find(']') {
62                    let end = start + rel_end + 1;
63                    let mut s = String::with_capacity(out.len() + real.len());
64                    s.push_str(&out[..start]);
65                    s.push_str(real);
66                    s.push_str(&out[end..]);
67                    out = s;
68                }
69            }
70        }
71        for (i, path) in self.pasted_images.iter().enumerate() {
72            let token = format!("[Image #{}]", i + 1);
73            let replacement = format!("@{}", path.display());
74            out = out.replace(&token, &replacement);
75        }
76        out
77    }
78
79    pub fn complete_suggestion(&mut self) {
80        if !self.suggestion.is_empty() {
81            self.buffer.push_str(&self.suggestion);
82            self.cursor_pos = self.buffer.len();
83            self.suggestion.clear();
84        }
85    }
86
87    pub fn insert_char(&mut self, c: char) {
88        if self.cursor_pos >= self.buffer.len() {
89            self.buffer.push(c);
90        } else {
91            self.buffer.insert(self.cursor_pos, c);
92        }
93        self.cursor_pos += c.len_utf8();
94    }
95
96    pub fn delete_char(&mut self) {
97        if self.cursor_pos > 0 {
98            let prev = self.buffer[..self.cursor_pos]
99                .char_indices()
100                .next_back()
101                .map(|(i, _)| i)
102                .unwrap_or(0);
103            self.buffer.drain(prev..self.cursor_pos);
104            self.cursor_pos = prev;
105        }
106    }
107
108    pub fn delete_char_forward(&mut self) {
109        if self.cursor_pos < self.buffer.len() {
110            let next = self.buffer[self.cursor_pos..]
111                .char_indices()
112                .nth(1)
113                .map(|(i, _)| self.cursor_pos + i)
114                .unwrap_or(self.buffer.len());
115            self.buffer.drain(self.cursor_pos..next);
116        }
117    }
118
119    pub fn move_cursor_left(&mut self) {
120        if self.cursor_pos > 0 {
121            self.cursor_pos = self.buffer[..self.cursor_pos]
122                .char_indices()
123                .next_back()
124                .map(|(i, _)| i)
125                .unwrap_or(0);
126        }
127    }
128
129    pub fn move_cursor_right(&mut self) {
130        if self.cursor_pos < self.buffer.len() {
131            self.cursor_pos = self.buffer[self.cursor_pos..]
132                .char_indices()
133                .nth(1)
134                .map(|(i, _)| self.cursor_pos + i)
135                .unwrap_or(self.buffer.len());
136        }
137    }
138
139    pub fn move_word_left(&mut self) {
140        let s = &self.buffer[..self.cursor_pos];
141        let trimmed = s.trim_end_matches(|c: char| !c.is_alphanumeric());
142        let word_end = trimmed.rfind(|c: char| !c.is_alphanumeric()).map(|i| i + 1).unwrap_or(0);
143        self.cursor_pos = word_end;
144    }
145
146    pub fn move_word_right(&mut self) {
147        let s = &self.buffer[self.cursor_pos..];
148        let skip = s.find(|c: char| c.is_alphanumeric()).unwrap_or(s.len());
149        let after = &s[skip..];
150        let word_end = after.find(|c: char| !c.is_alphanumeric()).unwrap_or(after.len());
151        self.cursor_pos = (self.cursor_pos + skip + word_end).min(self.buffer.len());
152    }
153
154    pub fn history_prev(&mut self) {
155        if self.history.is_empty() { return; }
156        let idx = match self.history_index {
157            None => self.history.len() - 1,
158            Some(i) if i > 0 => i - 1,
159            Some(i) => i,
160        };
161        self.history_index = Some(idx);
162        self.buffer = self.history[idx].clone();
163        self.cursor_pos = self.buffer.len();
164    }
165
166    pub fn history_next(&mut self) {
167        match self.history_index {
168            None => {}
169            Some(i) if i + 1 < self.history.len() => {
170                let idx = i + 1;
171                self.history_index = Some(idx);
172                self.buffer = self.history[idx].clone();
173                self.cursor_pos = self.buffer.len();
174            }
175            _ => { self.history_index = None; self.buffer.clear(); self.cursor_pos = 0; }
176        }
177    }
178
179    pub fn push_history(&mut self, text: String) {
180        if !text.is_empty() && self.history.last().map(|s| s.as_str()) != Some(&text) {
181            self.history.push(text);
182        }
183        self.history_index = None;
184    }
185
186    pub fn clear(&mut self) {
187        self.buffer.clear();
188        self.cursor_pos = 0;
189        self.history_index = None;
190        self.pasted_buffer = None;
191        self.pasted_images.clear();
192    }
193
194    pub fn get_display_text(&self) -> &str { &self.buffer }
195
196    /// (line_index, display_column) for the current cursor byte position.
197    /// Display column is in terminal cells (unicode-width aware), not chars,
198    /// so emoji and CJK don't push the cursor off.
199    pub fn cursor_line_col(&self) -> (usize, usize) {
200        use unicode_width::UnicodeWidthChar;
201        let mut line = 0;
202        let mut last_nl = 0;
203        for (i, b) in self.buffer.as_bytes().iter().enumerate() {
204            if i >= self.cursor_pos { break; }
205            if *b == b'\n' {
206                line += 1;
207                last_nl = i + 1;
208            }
209        }
210        let cursor = self.cursor_pos.min(self.buffer.len());
211        let col: usize = self.buffer[last_nl..cursor]
212            .chars()
213            .map(|c| c.width().unwrap_or(0))
214            .sum();
215        (line, col)
216    }
217
218    pub fn line_count(&self) -> usize {
219        self.buffer.lines().count().max(1)
220            + if self.buffer.ends_with('\n') { 1 } else { 0 }
221    }
222
223    pub fn cursor_up_line(&mut self) -> bool {
224        let (line, col) = self.cursor_line_col();
225        if line == 0 { return false; }
226        let lines: Vec<&str> = self.buffer.split('\n').collect();
227        let target_line = line - 1;
228        let target_col = col.min(lines[target_line].chars().count());
229        let mut pos = 0usize;
230        for l in lines.iter().take(target_line) {
231            pos += l.len() + 1;
232        }
233        pos += lines[target_line]
234            .char_indices()
235            .nth(target_col)
236            .map(|(i, _)| i)
237            .unwrap_or_else(|| lines[target_line].len());
238        self.cursor_pos = pos;
239        true
240    }
241
242    pub fn cursor_down_line(&mut self) -> bool {
243        let (line, col) = self.cursor_line_col();
244        let lines: Vec<&str> = self.buffer.split('\n').collect();
245        if line + 1 >= lines.len() { return false; }
246        let target_line = line + 1;
247        let target_col = col.min(lines[target_line].chars().count());
248        let mut pos = 0usize;
249        for l in lines.iter().take(target_line) {
250            pos += l.len() + 1;
251        }
252        pos += lines[target_line]
253            .char_indices()
254            .nth(target_col)
255            .map(|(i, _)| i)
256            .unwrap_or_else(|| lines[target_line].len());
257        self.cursor_pos = pos;
258        true
259    }
260}
261
262impl Default for InputState {
263    fn default() -> Self { Self::new() }
264}