Skip to main content

oxiui_text/
input.rs

1//! Single-line editable text input widget state.
2//!
3//! [`TextInput`] is a pure data structure — rendering is handled by adapters.
4
5use crate::layout::TextLayout;
6use crate::selection::Selection;
7
8// ── Helper ────────────────────────────────────────────────────────────────────
9
10/// Find the largest index `≤ i` that is a char boundary in `s`.
11///
12/// This is a stable-toolchain substitute for the nightly `str::floor_char_boundary`.
13fn floor_char_boundary(s: &str, i: usize) -> usize {
14    let mut pos = i.min(s.len());
15    while pos > 0 && !s.is_char_boundary(pos) {
16        pos -= 1;
17    }
18    pos
19}
20
21// ── TextInput ─────────────────────────────────────────────────────────────────
22
23/// Single-line editable text input widget state.
24///
25/// Wraps the text string, cursor byte-offset, selection, horizontal scroll,
26/// and optional password masking.  All editing operations keep the cursor
27/// and selection consistent.
28#[derive(Debug, Clone)]
29pub struct TextInput {
30    text: String,
31    /// Byte offset of the cursor position.
32    cursor: usize,
33    /// Anchor/focus selection over the text.
34    selection: Selection,
35    /// Horizontal scroll in logical pixels.
36    scroll_offset: f32,
37    /// Masking character.  `None` = plain text; `Some(c)` = password mode.
38    mask_char: Option<char>,
39    /// When `true` (and `mask_char.is_some()`), show the real text.
40    show_masked: bool,
41}
42
43impl TextInput {
44    /// Create an empty `TextInput` with no masking.
45    pub fn new() -> Self {
46        Self {
47            text: String::new(),
48            cursor: 0,
49            selection: Selection::new(0),
50            scroll_offset: 0.0,
51            mask_char: None,
52            show_masked: false,
53        }
54    }
55
56    /// Create a `TextInput` pre-populated with `text`, cursor at the end.
57    pub fn with_text(text: impl Into<String>) -> Self {
58        let text = text.into();
59        let len = text.len();
60        Self {
61            text,
62            cursor: len,
63            selection: Selection::new(len),
64            scroll_offset: 0.0,
65            mask_char: None,
66            show_masked: false,
67        }
68    }
69
70    /// Enable password masking with the bullet character U+2022.
71    pub fn with_password(mut self) -> Self {
72        self.mask_char = Some('\u{2022}');
73        self
74    }
75
76    // ── Getters ───────────────────────────────────────────────────────────────
77
78    /// Borrow the raw (un-masked) text.
79    pub fn text(&self) -> &str {
80        &self.text
81    }
82
83    /// Current cursor byte offset.
84    pub fn cursor(&self) -> usize {
85        self.cursor
86    }
87
88    /// Current selection.
89    pub fn selection(&self) -> &Selection {
90        &self.selection
91    }
92
93    /// Current horizontal scroll offset in logical pixels.
94    pub fn scroll_offset(&self) -> f32 {
95        self.scroll_offset
96    }
97
98    /// Returns `true` when password masking is active.
99    pub fn is_password(&self) -> bool {
100        self.mask_char.is_some()
101    }
102
103    /// Returns `true` when the underlying password text is currently visible.
104    pub fn is_showing_password(&self) -> bool {
105        self.show_masked
106    }
107
108    /// The text to display — masked when in password mode (unless show-password
109    /// is toggled on).
110    pub fn display_text(&self) -> String {
111        if let Some(mask) = self.mask_char {
112            if !self.show_masked {
113                return self.text.chars().map(|_| mask).collect();
114            }
115        }
116        self.text.clone()
117    }
118
119    /// Toggle the show/hide state for password fields.  No-op on plain fields.
120    pub fn toggle_show_password(&mut self) {
121        self.show_masked = !self.show_masked;
122    }
123
124    /// Borrow the currently selected slice of the raw text.
125    ///
126    /// Returns an empty string when the selection is collapsed.
127    pub fn selected_text(&self) -> &str {
128        if self.selection.is_collapsed() {
129            return "";
130        }
131        let (start, end) = self.selection.normalized();
132        let start = floor_char_boundary(&self.text, start.min(self.text.len()));
133        let end = floor_char_boundary(&self.text, end.min(self.text.len()));
134        &self.text[start..end]
135    }
136
137    // ── Editing operations ────────────────────────────────────────────────────
138
139    /// Insert a string at the cursor position, replacing any active selection.
140    pub fn insert(&mut self, s: &str) {
141        self.delete_selection();
142        let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
143        self.text.insert_str(pos, s);
144        self.cursor = pos + s.len();
145        self.selection = Selection::new(self.cursor);
146    }
147
148    /// Insert a single character at the cursor position.
149    pub fn insert_char(&mut self, c: char) {
150        let mut buf = [0u8; 4];
151        let s = c.encode_utf8(&mut buf);
152        self.insert(s);
153    }
154
155    /// Delete the character immediately before the cursor (backspace).
156    ///
157    /// If there is an active selection, deletes the selection instead.
158    pub fn delete_backward(&mut self) {
159        if !self.selection.is_collapsed() {
160            self.delete_selection();
161            return;
162        }
163        if self.cursor == 0 {
164            return;
165        }
166        let pos = floor_char_boundary(&self.text, self.cursor);
167        let mut prev = pos.saturating_sub(1);
168        while prev > 0 && !self.text.is_char_boundary(prev) {
169            prev -= 1;
170        }
171        self.text.replace_range(prev..pos, "");
172        self.cursor = prev;
173        self.selection = Selection::new(self.cursor);
174    }
175
176    /// Delete the character immediately after the cursor (Delete key).
177    ///
178    /// If there is an active selection, deletes the selection instead.
179    pub fn delete_forward(&mut self) {
180        if !self.selection.is_collapsed() {
181            self.delete_selection();
182            return;
183        }
184        let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
185        if pos >= self.text.len() {
186            return;
187        }
188        let mut next = pos + 1;
189        while next < self.text.len() && !self.text.is_char_boundary(next) {
190            next += 1;
191        }
192        self.text.replace_range(pos..next, "");
193        self.selection = Selection::new(self.cursor);
194    }
195
196    // ── Cursor movement ───────────────────────────────────────────────────────
197
198    /// Move the cursor one character to the left.
199    ///
200    /// When `shift` is `false` and there is an active selection, the cursor
201    /// jumps to the start of the selection without extending it.
202    pub fn move_left(&mut self, shift: bool) {
203        if !shift && !self.selection.is_collapsed() {
204            let (start, _) = self.selection.normalized();
205            self.cursor = start;
206        } else if self.cursor > 0 {
207            let mut pos = self.cursor.saturating_sub(1);
208            while pos > 0 && !self.text.is_char_boundary(pos) {
209                pos -= 1;
210            }
211            self.cursor = pos;
212        }
213        if shift {
214            self.selection = Selection {
215                anchor: self.selection.anchor,
216                focus: self.cursor,
217            };
218        } else {
219            self.selection = Selection::new(self.cursor);
220        }
221    }
222
223    /// Move the cursor one character to the right.
224    ///
225    /// When `shift` is `false` and there is an active selection, the cursor
226    /// jumps to the end of the selection without extending it.
227    pub fn move_right(&mut self, shift: bool) {
228        if !shift && !self.selection.is_collapsed() {
229            let (_, end) = self.selection.normalized();
230            self.cursor = end;
231        } else {
232            let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
233            if pos < self.text.len() {
234                let mut next = pos + 1;
235                while next < self.text.len() && !self.text.is_char_boundary(next) {
236                    next += 1;
237                }
238                self.cursor = next;
239            }
240        }
241        if shift {
242            self.selection = Selection {
243                anchor: self.selection.anchor,
244                focus: self.cursor,
245            };
246        } else {
247            self.selection = Selection::new(self.cursor);
248        }
249    }
250
251    /// Move the cursor to the beginning of the text (Home key).
252    pub fn move_home(&mut self, shift: bool) {
253        self.cursor = 0;
254        if shift {
255            self.selection = Selection {
256                anchor: self.selection.anchor,
257                focus: 0,
258            };
259        } else {
260            self.selection = Selection::new(0);
261        }
262    }
263
264    /// Move the cursor to the end of the text (End key).
265    pub fn move_end(&mut self, shift: bool) {
266        self.cursor = self.text.len();
267        if shift {
268            self.selection = Selection {
269                anchor: self.selection.anchor,
270                focus: self.text.len(),
271            };
272        } else {
273            self.selection = Selection::new(self.text.len());
274        }
275    }
276
277    /// Move the cursor one word to the left (Ctrl+Left).
278    pub fn move_word_left(&mut self, shift: bool) {
279        let new_focus = Selection::extend_word_backward(&self.text, self.cursor);
280        self.cursor = new_focus;
281        if shift {
282            self.selection = Selection {
283                anchor: self.selection.anchor,
284                focus: new_focus,
285            };
286        } else {
287            self.selection = Selection::new(new_focus);
288        }
289    }
290
291    /// Move the cursor one word to the right (Ctrl+Right).
292    pub fn move_word_right(&mut self, shift: bool) {
293        let new_focus = Selection::extend_word_forward(&self.text, self.cursor);
294        self.cursor = new_focus;
295        if shift {
296            self.selection = Selection {
297                anchor: self.selection.anchor,
298                focus: new_focus,
299            };
300        } else {
301            self.selection = Selection::new(new_focus);
302        }
303    }
304
305    /// Move the cursor to the byte position nearest to the pixel x-coordinate.
306    ///
307    /// Uses `TextLayout::hit_test` for positioning.  When `shift` is `true`,
308    /// the selection anchor is preserved (keyboard-extend behaviour).
309    pub fn move_cursor_to_x(&mut self, x: f32, layout: &TextLayout, shift: bool) {
310        let byte_offset = layout.hit_test(x, 0.0);
311        self.cursor = byte_offset;
312        if shift {
313            self.selection = Selection {
314                anchor: self.selection.anchor,
315                focus: byte_offset,
316            };
317        } else {
318            self.selection = Selection::new(byte_offset);
319        }
320    }
321
322    /// Single-click — move the cursor and collapse the selection.
323    pub fn click(&mut self, x: f32, layout: &TextLayout) {
324        self.move_cursor_to_x(x, layout, false);
325    }
326
327    /// Double-click — select the word nearest to the click position.
328    pub fn double_click(&mut self, x: f32, layout: &TextLayout) {
329        let pos = layout.hit_test(x, 0.0);
330        let word_start = Selection::extend_word_backward(&self.text, pos);
331        let word_end = Selection::extend_word_forward(&self.text, pos);
332        self.cursor = word_end;
333        self.selection = Selection {
334            anchor: word_start,
335            focus: word_end,
336        };
337    }
338
339    /// Triple-click — select all text.
340    pub fn triple_click(&mut self) {
341        self.cursor = self.text.len();
342        self.selection = Selection {
343            anchor: 0,
344            focus: self.text.len(),
345        };
346    }
347
348    /// Select all text (Ctrl+A equivalent).
349    pub fn select_all(&mut self) {
350        self.triple_click();
351    }
352
353    // ── Private helpers ───────────────────────────────────────────────────────
354
355    fn delete_selection(&mut self) {
356        if self.selection.is_collapsed() {
357            return;
358        }
359        let (start, end) = self.selection.normalized();
360        let start = floor_char_boundary(&self.text, start.min(self.text.len()));
361        let end = floor_char_boundary(&self.text, end.min(self.text.len()));
362        self.text.replace_range(start..end, "");
363        self.cursor = start;
364        self.selection = Selection::new(start);
365    }
366}
367
368impl Default for TextInput {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374// ── Tests ─────────────────────────────────────────────────────────────────────
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::layout::{TextAlign, TextLayout};
380    use crate::{GlyphPosition, ShapedText};
381
382    /// Build a minimal single-line layout from a string (using fixed 8px/char).
383    fn fake_layout(text: &str) -> TextLayout {
384        let char_w = 8.0_f32;
385        let glyphs: Vec<GlyphPosition> = text
386            .char_indices()
387            .enumerate()
388            .map(|(i, (byte_off, _))| GlyphPosition {
389                byte_offset: byte_off,
390                x: i as f32 * char_w,
391                y: 0.0,
392                width: char_w,
393                height: 16.0,
394            })
395            .collect();
396        let total_width = glyphs.len() as f32 * char_w;
397        let shaped = ShapedText {
398            lines: vec![glyphs],
399            total_width,
400            total_height: 16.0,
401        };
402        TextLayout {
403            shaped,
404            align: TextAlign::Left,
405            bounds: (total_width, 16.0),
406        }
407    }
408
409    #[test]
410    fn insert_at_cursor() {
411        let mut input = TextInput::new();
412        input.insert("hello");
413        assert_eq!(input.text(), "hello");
414        assert_eq!(input.cursor(), 5);
415    }
416
417    #[test]
418    fn delete_backward_basic() {
419        let mut input = TextInput::with_text("hello");
420        input.delete_backward();
421        assert_eq!(input.text(), "hell");
422        assert_eq!(input.cursor(), 4);
423    }
424
425    #[test]
426    fn delete_backward_no_panic_at_zero() {
427        let mut input = TextInput::new();
428        input.delete_backward(); // cursor = 0 → no-op, no panic
429        assert_eq!(input.text(), "");
430    }
431
432    #[test]
433    fn delete_forward_basic() {
434        let mut input = TextInput::with_text("hello");
435        input.move_home(false);
436        input.delete_forward();
437        assert_eq!(input.text(), "ello");
438        assert_eq!(input.cursor(), 0);
439    }
440
441    #[test]
442    fn move_left_right_simple() {
443        let mut input = TextInput::with_text("ab");
444        input.move_home(false);
445        input.move_right(false);
446        assert_eq!(input.cursor(), 1);
447        input.move_left(false);
448        assert_eq!(input.cursor(), 0);
449    }
450
451    #[test]
452    fn move_word_left_right() {
453        let mut input = TextInput::with_text("hello world");
454        // Cursor starts at end (11)
455        input.move_word_left(false);
456        assert_eq!(
457            input.cursor(),
458            6,
459            "word-left should land at start of 'world'"
460        );
461        input.move_word_right(false);
462        assert_eq!(
463            input.cursor(),
464            11,
465            "word-right should land at end of 'world'"
466        );
467    }
468
469    #[test]
470    fn move_home_end() {
471        let mut input = TextInput::with_text("hello");
472        input.move_home(false);
473        assert_eq!(input.cursor(), 0);
474        input.move_end(false);
475        assert_eq!(input.cursor(), 5);
476    }
477
478    #[test]
479    fn triple_click_selects_all() {
480        let mut input = TextInput::with_text("hello world");
481        input.triple_click();
482        assert_eq!(input.selected_text(), "hello world");
483    }
484
485    #[test]
486    fn select_all() {
487        let mut input = TextInput::with_text("hello world");
488        input.select_all();
489        assert_eq!(input.selected_text(), "hello world");
490    }
491
492    #[test]
493    fn double_click_selects_word() {
494        let mut input = TextInput::with_text("hello world");
495        let layout = fake_layout("hello world");
496        // Click in the middle of "world" (index 7 → x=56)
497        input.double_click(56.0, &layout);
498        let sel = input.selected_text();
499        // Should select "world" or at least be non-empty
500        assert!(!sel.is_empty(), "double-click must select a word");
501    }
502
503    #[test]
504    fn password_mask_same_length() {
505        let input = TextInput::with_text("secret").with_password();
506        let display = input.display_text();
507        let orig_chars = "secret".chars().count();
508        let disp_chars = display.chars().count();
509        assert_eq!(
510            orig_chars, disp_chars,
511            "masked text must have the same char count"
512        );
513        assert!(
514            !display.contains('s'),
515            "masked text must not contain raw characters"
516        );
517    }
518
519    #[test]
520    fn password_toggle_show_hide() {
521        let mut input = TextInput::with_text("secret").with_password();
522        assert!(!input.is_showing_password());
523        let masked = input.display_text();
524        input.toggle_show_password();
525        assert!(input.is_showing_password());
526        let visible = input.display_text();
527        assert_eq!(visible, "secret");
528        assert_ne!(masked, visible);
529    }
530
531    #[test]
532    fn insert_replaces_selection() {
533        let mut input = TextInput::with_text("hello world");
534        input.select_all();
535        input.insert("replaced");
536        assert_eq!(input.text(), "replaced");
537    }
538
539    #[test]
540    fn shift_right_extends_selection() {
541        let mut input = TextInput::with_text("hello");
542        input.move_home(false);
543        input.move_right(true);
544        input.move_right(true);
545        assert_eq!(input.selected_text(), "he");
546    }
547}