Skip to main content

oo_ide/widgets/
input_field.rs

1use ratatui::{
2    layout::Rect,
3    style::{Color, Modifier, Style},
4    text::{Line, Span},
5    widgets::Paragraph,
6    Frame,
7};
8
9use crate::input::{Key, Modifiers};
10use crate::theme::Theme;
11
12/// Convert a key event into an [`InputFieldOp`], if the key maps to a
13/// single-line text-editing action.
14///
15/// This is a convenience function that de-duplicates the `key_to_filter_op` /
16/// `key_to_input_op` helpers that were previously copy-pasted across views.
17pub fn key_to_op(key: crate::input::KeyEvent) -> Option<InputFieldOp> {
18    match (key.modifiers, key.key) {
19        (m, Key::Char(c)) if !m.contains(Modifiers::CTRL) && !m.contains(Modifiers::ALT) => {
20            Some(InputFieldOp::InsertChar(c))
21        }
22        (_, Key::Backspace) => Some(InputFieldOp::Backspace),
23        (_, Key::Delete) => Some(InputFieldOp::DeleteForward),
24        (_, Key::ArrowLeft) => Some(InputFieldOp::CursorLeft),
25        (_, Key::ArrowRight) => Some(InputFieldOp::CursorRight),
26        (_, Key::Home) => Some(InputFieldOp::CursorHome),
27        (_, Key::End) => Some(InputFieldOp::CursorEnd),
28        _ => None,
29    }
30}
31
32/// A single-line text input with byte-offset cursor.
33///
34/// Replaces the ad-hoc `TextField` in `SearchReplaceView`, the `filter: String`
35/// pattern in `FileSelector`/`CommandRunner`/`LogView`, and the raw `String`
36/// inputs in `CommitWindow`.
37#[derive(Debug, Default, Clone)]
38pub struct InputField {
39    text: String,
40    /// Byte offset of the cursor within `text`.
41    cursor: usize,
42    /// Label shown before the input (e.g. "Filter:", "Query:").
43    label: String,
44}
45
46/// Operations that can be applied to an `InputField`.
47#[derive(Debug, Clone)]
48pub enum InputFieldOp {
49    InsertChar(char),
50    Backspace,
51    DeleteForward,
52    CursorLeft,
53    CursorRight,
54    CursorHome,
55    CursorEnd,
56    /// Replace entire text (used by async results or programmatic resets).
57    SetText(String),
58}
59
60impl InputField {
61    pub fn new(label: impl Into<String>) -> Self {
62        Self {
63            text: String::new(),
64            cursor: 0,
65            label: label.into(),
66        }
67    }
68
69    pub fn with_text(mut self, text: impl Into<String>) -> Self {
70        self.text = text.into();
71        self.cursor = self.text.len();
72        self
73    }
74
75    pub fn text(&self) -> &str {
76        &self.text
77    }
78
79    pub fn cursor(&self) -> usize {
80        self.cursor
81    }
82
83    pub fn label(&self) -> &str {
84        &self.label
85    }
86
87    pub fn set_label(&mut self, label: impl Into<String>) {
88        self.label = label.into();
89    }
90
91    pub fn is_empty(&self) -> bool {
92        self.text.is_empty()
93    }
94
95    // --- Mutations ---------------------------------------------------------
96
97    pub fn insert_char(&mut self, c: char) {
98        self.text.insert(self.cursor, c);
99        self.cursor += c.len_utf8();
100    }
101
102    pub fn backspace(&mut self) {
103        if self.cursor == 0 {
104            return;
105        }
106        let prev = self.text[..self.cursor]
107            .char_indices()
108            .next_back()
109            .map(|(i, _)| i)
110            .unwrap_or(0);
111        self.text.drain(prev..self.cursor);
112        self.cursor = prev;
113    }
114
115    pub fn delete_forward(&mut self) {
116        if self.cursor >= self.text.len() {
117            return;
118        }
119        let next = self.text[self.cursor..]
120            .char_indices()
121            .nth(1)
122            .map(|(i, _)| self.cursor + i)
123            .unwrap_or(self.text.len());
124        self.text.drain(self.cursor..next);
125    }
126
127    pub fn cursor_left(&mut self) {
128        if self.cursor == 0 {
129            return;
130        }
131        self.cursor = self.text[..self.cursor]
132            .char_indices()
133            .next_back()
134            .map(|(i, _)| i)
135            .unwrap_or(0);
136    }
137
138    pub fn cursor_right(&mut self) {
139        if self.cursor >= self.text.len() {
140            return;
141        }
142        self.cursor = self.text[self.cursor..]
143            .char_indices()
144            .nth(1)
145            .map(|(i, _)| self.cursor + i)
146            .unwrap_or(self.text.len());
147    }
148
149    pub fn cursor_home(&mut self) {
150        self.cursor = 0;
151    }
152
153    pub fn cursor_end(&mut self) {
154        self.cursor = self.text.len();
155    }
156
157    pub fn set_text(&mut self, text: String) {
158        self.text = text;
159        self.cursor = self.text.len();
160    }
161
162    /// Apply an `InputFieldOp` to this field.
163    pub fn apply(&mut self, op: &InputFieldOp) {
164        match op {
165            InputFieldOp::InsertChar(c) => self.insert_char(*c),
166            InputFieldOp::Backspace => self.backspace(),
167            InputFieldOp::DeleteForward => self.delete_forward(),
168            InputFieldOp::CursorLeft => self.cursor_left(),
169            InputFieldOp::CursorRight => self.cursor_right(),
170            InputFieldOp::CursorHome => self.cursor_home(),
171            InputFieldOp::CursorEnd => self.cursor_end(),
172            InputFieldOp::SetText(t) => self.set_text(t.clone()),
173        }
174    }
175
176    // --- Rendering ---------------------------------------------------------
177
178    /// Render the input field into the given area.
179    pub fn render(&self, frame: &mut Frame, area: Rect, focused: bool, theme: &Theme) {
180        if area.height == 0 || area.width == 0 {
181            return;
182        }
183
184        let bg = if focused {
185            theme.bg_active()
186        } else {
187            theme.bg_inactive()
188        };
189        let fg = if focused {
190            theme.fg_active()
191        } else {
192            theme.fg_dim()
193        };
194        let accent = theme.accent();
195
196        let mut spans = Vec::new();
197
198        // Label
199        if !self.label.is_empty() {
200            spans.push(Span::styled(
201                format!("  {}: ", self.label),
202                Style::new().fg(accent).add_modifier(Modifier::BOLD),
203            ));
204        }
205
206        if focused {
207            // Split text at cursor and show block cursor
208            let (before, after) = self.text.split_at(self.cursor);
209            spans.push(Span::styled(before.to_string(), Style::new().fg(fg).bg(bg)));
210
211            // Cursor character (or space if at end)
212            let cursor_char = after.chars().next().unwrap_or(' ');
213            spans.push(Span::styled(
214                cursor_char.to_string(),
215                Style::new()
216                    .fg(Color::Black)
217                    .bg(Color::White)
218                    .add_modifier(Modifier::BOLD),
219            ));
220
221            // Rest after cursor
222            if !after.is_empty() {
223                let rest_start = cursor_char.len_utf8();
224                if rest_start < after.len() {
225                    spans.push(Span::styled(
226                        after[rest_start..].to_string(),
227                        Style::new().fg(fg).bg(bg),
228                    ));
229                }
230            }
231        } else {
232            spans.push(Span::styled(
233                self.text.clone(),
234                Style::new().fg(fg).bg(bg),
235            ));
236        }
237
238        frame.render_widget(
239            Paragraph::new(Line::from(spans)).style(Style::new().bg(bg)),
240            area,
241        );
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_new_empty() {
251        let field = InputField::new("Filter");
252        assert_eq!(field.text(), "");
253        assert_eq!(field.cursor(), 0);
254        assert_eq!(field.label(), "Filter");
255        assert!(field.is_empty());
256    }
257
258    #[test]
259    fn test_with_text() {
260        let field = InputField::new("").with_text("hello");
261        assert_eq!(field.text(), "hello");
262        assert_eq!(field.cursor(), 5);
263    }
264
265    #[test]
266    fn test_insert_char() {
267        let mut field = InputField::new("");
268        field.insert_char('a');
269        field.insert_char('b');
270        assert_eq!(field.text(), "ab");
271        assert_eq!(field.cursor(), 2);
272    }
273
274    #[test]
275    fn test_insert_char_middle() {
276        let mut field = InputField::new("").with_text("ac");
277        field.cursor_left();
278        field.insert_char('b');
279        assert_eq!(field.text(), "abc");
280        assert_eq!(field.cursor(), 2);
281    }
282
283    #[test]
284    fn test_backspace() {
285        let mut field = InputField::new("").with_text("abc");
286        field.backspace();
287        assert_eq!(field.text(), "ab");
288        assert_eq!(field.cursor(), 2);
289    }
290
291    #[test]
292    fn test_backspace_at_start() {
293        let mut field = InputField::new("").with_text("abc");
294        field.cursor_home();
295        field.backspace();
296        assert_eq!(field.text(), "abc");
297        assert_eq!(field.cursor(), 0);
298    }
299
300    #[test]
301    fn test_backspace_empty() {
302        let mut field = InputField::new("");
303        field.backspace();
304        assert_eq!(field.text(), "");
305        assert_eq!(field.cursor(), 0);
306    }
307
308    #[test]
309    fn test_delete_forward() {
310        let mut field = InputField::new("").with_text("abc");
311        field.cursor_home();
312        field.delete_forward();
313        assert_eq!(field.text(), "bc");
314        assert_eq!(field.cursor(), 0);
315    }
316
317    #[test]
318    fn test_delete_forward_at_end() {
319        let mut field = InputField::new("").with_text("abc");
320        field.delete_forward();
321        assert_eq!(field.text(), "abc");
322        assert_eq!(field.cursor(), 3);
323    }
324
325    #[test]
326    fn test_cursor_left_right() {
327        let mut field = InputField::new("").with_text("abc");
328        assert_eq!(field.cursor(), 3);
329        field.cursor_left();
330        assert_eq!(field.cursor(), 2);
331        field.cursor_left();
332        assert_eq!(field.cursor(), 1);
333        field.cursor_right();
334        assert_eq!(field.cursor(), 2);
335    }
336
337    #[test]
338    fn test_cursor_left_at_start() {
339        let mut field = InputField::new("").with_text("a");
340        field.cursor_home();
341        field.cursor_left();
342        assert_eq!(field.cursor(), 0);
343    }
344
345    #[test]
346    fn test_cursor_right_at_end() {
347        let mut field = InputField::new("").with_text("a");
348        field.cursor_right();
349        assert_eq!(field.cursor(), 1);
350    }
351
352    #[test]
353    fn test_cursor_home_end() {
354        let mut field = InputField::new("").with_text("hello");
355        field.cursor_home();
356        assert_eq!(field.cursor(), 0);
357        field.cursor_end();
358        assert_eq!(field.cursor(), 5);
359    }
360
361    #[test]
362    fn test_set_text() {
363        let mut field = InputField::new("");
364        field.set_text("new value".to_string());
365        assert_eq!(field.text(), "new value");
366        assert_eq!(field.cursor(), 9);
367    }
368
369    #[test]
370    fn test_utf8_multibyte() {
371        let mut field = InputField::new("");
372        field.insert_char('é');
373        field.insert_char('ñ');
374        assert_eq!(field.text(), "éñ");
375        // é is 2 bytes, ñ is 2 bytes
376        assert_eq!(field.cursor(), 4);
377        field.cursor_left();
378        assert_eq!(field.cursor(), 2);
379        field.backspace();
380        assert_eq!(field.text(), "ñ");
381        assert_eq!(field.cursor(), 0);
382    }
383
384    #[test]
385    fn test_utf8_emoji() {
386        let mut field = InputField::new("");
387        field.insert_char('🎉');
388        assert_eq!(field.text(), "🎉");
389        assert_eq!(field.cursor(), 4); // emoji is 4 bytes
390        field.backspace();
391        assert_eq!(field.text(), "");
392        assert_eq!(field.cursor(), 0);
393    }
394
395    #[test]
396    fn test_apply_op() {
397        let mut field = InputField::new("");
398        field.apply(&InputFieldOp::InsertChar('h'));
399        field.apply(&InputFieldOp::InsertChar('i'));
400        assert_eq!(field.text(), "hi");
401        field.apply(&InputFieldOp::CursorLeft);
402        field.apply(&InputFieldOp::Backspace);
403        assert_eq!(field.text(), "i");
404        field.apply(&InputFieldOp::CursorHome);
405        field.apply(&InputFieldOp::DeleteForward);
406        assert_eq!(field.text(), "");
407    }
408
409    #[test]
410    fn test_apply_set_text() {
411        let mut field = InputField::new("");
412        field.apply(&InputFieldOp::SetText("replaced".to_string()));
413        assert_eq!(field.text(), "replaced");
414        assert_eq!(field.cursor(), 8);
415    }
416
417    #[test]
418    fn test_delete_forward_middle() {
419        let mut field = InputField::new("").with_text("abc");
420        field.cursor_home();
421        field.cursor_right();
422        field.delete_forward();
423        assert_eq!(field.text(), "ac");
424        assert_eq!(field.cursor(), 1);
425    }
426
427    #[test]
428    fn test_backspace_middle() {
429        let mut field = InputField::new("").with_text("abc");
430        field.cursor_left();
431        field.backspace();
432        assert_eq!(field.text(), "ac");
433        assert_eq!(field.cursor(), 1);
434    }
435}