Skip to main content

jag_ui/elements/
input_box.rs

1//! Single-line text input element.
2
3use jag_draw::{Brush, Color, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13use super::text_align::TextAlign;
14
15/// A single-line text input with cursor, placeholder, and basic editing.
16///
17/// This is a simplified standalone version that stores text as a `String`
18/// and tracks a cursor byte position.  It does not depend on external
19/// text-layout crates, making it suitable for lightweight UI toolkits.
20pub struct InputBox {
21    /// Bounding rect of the input field.
22    pub rect: Rect,
23    /// Current text content.
24    pub text: String,
25    /// Font size in logical pixels.
26    pub text_size: f32,
27    /// Text color.
28    pub text_color: ColorLinPremul,
29    /// Placeholder text shown when empty.
30    pub placeholder: Option<String>,
31    /// Text alignment within the field.
32    pub text_align: TextAlign,
33    /// Whether this input is focused.
34    pub focused: bool,
35    /// Whether this input is disabled.
36    pub disabled: bool,
37    /// Background color.
38    pub bg_color: ColorLinPremul,
39    /// Border color.
40    pub border_color: ColorLinPremul,
41    /// Border width.
42    pub border_width: f32,
43    /// Corner radius.
44    pub corner_radius: f32,
45    /// Input type hint (e.g. "text", "password", "email").
46    pub input_type: String,
47    /// Validation error message.
48    pub validation_error: Option<String>,
49    /// Cursor byte position in `text`.
50    pub cursor_position: usize,
51    /// Horizontal scroll offset.
52    scroll_x: f32,
53    /// Horizontal padding.
54    padding_x: f32,
55    /// Vertical padding.
56    padding_y: f32,
57    /// Focus identifier.
58    pub focus_id: FocusId,
59}
60
61impl InputBox {
62    /// Create a new input box with sensible defaults.
63    pub fn new(rect: Rect) -> Self {
64        Self {
65            rect,
66            text: String::new(),
67            text_size: 14.0,
68            text_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
69            placeholder: None,
70            text_align: TextAlign::Left,
71            focused: false,
72            disabled: false,
73            bg_color: ColorLinPremul::from_srgba_u8([40, 40, 40, 255]),
74            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
75            border_width: 1.0,
76            corner_radius: 4.0,
77            input_type: "text".to_string(),
78            validation_error: None,
79            cursor_position: 0,
80            scroll_x: 0.0,
81            padding_x: 8.0,
82            padding_y: 4.0,
83            focus_id: FocusId(0),
84        }
85    }
86
87    /// Get the current text content.
88    pub fn text(&self) -> &str {
89        &self.text
90    }
91
92    /// Set the text content and move cursor to the end.
93    pub fn set_text(&mut self, text: impl Into<String>) {
94        self.text = text.into();
95        self.cursor_position = self.text.len();
96    }
97
98    /// Set the placeholder text.
99    pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
100        self.placeholder = Some(placeholder.into());
101    }
102
103    /// Insert text at the current cursor position.
104    pub fn insert_text(&mut self, s: &str) {
105        if self.disabled {
106            return;
107        }
108        self.text.insert_str(self.cursor_position, s);
109        self.cursor_position += s.len();
110    }
111
112    /// Delete one character before the cursor (backspace).
113    pub fn delete_char_before(&mut self) {
114        if self.disabled || self.cursor_position == 0 {
115            return;
116        }
117        // Find previous char boundary.
118        let prev = self.text[..self.cursor_position]
119            .char_indices()
120            .next_back()
121            .map(|(i, _)| i)
122            .unwrap_or(0);
123        self.text.drain(prev..self.cursor_position);
124        self.cursor_position = prev;
125    }
126
127    /// Delete one character after the cursor (delete key).
128    pub fn delete_char_after(&mut self) {
129        if self.disabled || self.cursor_position >= self.text.len() {
130            return;
131        }
132        let next = self.text[self.cursor_position..]
133            .char_indices()
134            .nth(1)
135            .map(|(i, _)| self.cursor_position + i)
136            .unwrap_or(self.text.len());
137        self.text.drain(self.cursor_position..next);
138    }
139
140    /// Move cursor left by one character.
141    pub fn move_cursor_left(&mut self) {
142        if self.cursor_position > 0 {
143            self.cursor_position = self.text[..self.cursor_position]
144                .char_indices()
145                .next_back()
146                .map(|(i, _)| i)
147                .unwrap_or(0);
148        }
149    }
150
151    /// Move cursor right by one character.
152    pub fn move_cursor_right(&mut self) {
153        if self.cursor_position < self.text.len() {
154            self.cursor_position = self.text[self.cursor_position..]
155                .char_indices()
156                .nth(1)
157                .map(|(i, _)| self.cursor_position + i)
158                .unwrap_or(self.text.len());
159        }
160    }
161
162    /// Move cursor to the beginning.
163    pub fn move_cursor_home(&mut self) {
164        self.cursor_position = 0;
165    }
166
167    /// Move cursor to the end.
168    pub fn move_cursor_end(&mut self) {
169        self.cursor_position = self.text.len();
170    }
171
172    /// Display text (masks password fields).
173    fn display_text(&self) -> String {
174        if self.input_type == "password" {
175            "\u{2022}".repeat(self.text.chars().count())
176        } else {
177            self.text.clone()
178        }
179    }
180
181    /// Hit-test the input field.
182    pub fn hit_test(&self, x: f32, y: f32) -> bool {
183        x >= self.rect.x
184            && x <= self.rect.x + self.rect.w
185            && y >= self.rect.y
186            && y <= self.rect.y + self.rect.h
187    }
188}
189
190impl Default for InputBox {
191    fn default() -> Self {
192        Self::new(Rect {
193            x: 0.0,
194            y: 0.0,
195            w: 200.0,
196            h: 32.0,
197        })
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Element trait
203// ---------------------------------------------------------------------------
204
205impl Element for InputBox {
206    fn rect(&self) -> Rect {
207        self.rect
208    }
209
210    fn set_rect(&mut self, rect: Rect) {
211        self.rect = rect;
212    }
213
214    fn render(&self, canvas: &mut Canvas, z: i32) {
215        // Background + border
216        let rrect = RoundedRect {
217            rect: self.rect,
218            radii: RoundedRadii {
219                tl: self.corner_radius,
220                tr: self.corner_radius,
221                br: self.corner_radius,
222                bl: self.corner_radius,
223            },
224        };
225
226        let has_error = self.validation_error.is_some();
227        let border_color = if has_error {
228            Color::rgba(220, 38, 38, 255)
229        } else if self.focused {
230            Color::rgba(63, 130, 246, 255)
231        } else {
232            self.border_color
233        };
234        let border_width = if has_error {
235            self.border_width.max(2.0)
236        } else if self.focused {
237            (self.border_width + 1.0).max(2.0)
238        } else {
239            self.border_width
240        };
241
242        jag_surface::shapes::draw_snapped_rounded_rectangle(
243            canvas,
244            rrect,
245            Some(Brush::Solid(self.bg_color)),
246            Some(border_width),
247            Some(Brush::Solid(border_color)),
248            z,
249        );
250
251        // Content area
252        let content_x = self.rect.x + self.padding_x;
253        let content_h = (self.rect.h - self.padding_y * 2.0).max(0.0);
254        let baseline_y = self.rect.y + self.padding_y + content_h * 0.5 + self.text_size * 0.35;
255
256        if self.text.is_empty() {
257            // Placeholder
258            if let Some(ref ph) = self.placeholder {
259                let ph_color = ColorLinPremul::from_srgba_u8([160, 160, 160, 255]);
260                canvas.draw_text_run_weighted(
261                    [content_x, baseline_y],
262                    ph.clone(),
263                    self.text_size,
264                    400.0,
265                    ph_color,
266                    z + 1,
267                );
268            }
269        } else {
270            // Text content
271            let display = self.display_text();
272            let text_x = content_x - self.scroll_x;
273            canvas.draw_text_run_weighted(
274                [text_x, baseline_y],
275                display,
276                self.text_size,
277                400.0,
278                self.text_color,
279                z + 1,
280            );
281        }
282
283        // Cursor (simple vertical line)
284        if self.focused {
285            let display = self.display_text();
286            let cursor_text = if self.cursor_position <= display.len() {
287                &display[..self.cursor_position]
288            } else {
289                &display
290            };
291            let cursor_offset = canvas.measure_text_width(cursor_text, self.text_size);
292            let cursor_x = content_x + cursor_offset - self.scroll_x;
293            let cursor_y = self.rect.y + self.padding_y + 2.0;
294            let cursor_h = content_h - 4.0;
295
296            canvas.fill_rect(
297                cursor_x,
298                cursor_y,
299                1.5,
300                cursor_h.max(0.0),
301                Brush::Solid(self.text_color),
302                z + 2,
303            );
304        }
305
306        // Validation error
307        if let Some(ref error_msg) = self.validation_error {
308            let error_size = (self.text_size * 0.85).max(12.0);
309            let baseline_offset = error_size * 0.8;
310            let top_gap = 3.0;
311            let error_y = self.rect.y + self.rect.h + top_gap + baseline_offset;
312            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
313
314            canvas.draw_text_run_weighted(
315                [self.rect.x + self.padding_x, error_y],
316                error_msg.clone(),
317                error_size,
318                400.0,
319                error_color,
320                z + 3,
321            );
322        }
323    }
324
325    fn focus_id(&self) -> Option<FocusId> {
326        Some(self.focus_id)
327    }
328}
329
330// ---------------------------------------------------------------------------
331// EventHandler trait
332// ---------------------------------------------------------------------------
333
334impl EventHandler for InputBox {
335    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
336        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
337            return EventResult::Ignored;
338        }
339        if self.hit_test(event.x, event.y) {
340            EventResult::Handled
341        } else {
342            EventResult::Ignored
343        }
344    }
345
346    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
347        if event.state != ElementState::Pressed || !self.focused || self.disabled {
348            return EventResult::Ignored;
349        }
350        match event.key {
351            KeyCode::Backspace => {
352                self.delete_char_before();
353                EventResult::Handled
354            }
355            KeyCode::Delete => {
356                self.delete_char_after();
357                EventResult::Handled
358            }
359            KeyCode::ArrowLeft => {
360                self.move_cursor_left();
361                EventResult::Handled
362            }
363            KeyCode::ArrowRight => {
364                self.move_cursor_right();
365                EventResult::Handled
366            }
367            KeyCode::Home => {
368                self.move_cursor_home();
369                EventResult::Handled
370            }
371            KeyCode::End => {
372                self.move_cursor_end();
373                EventResult::Handled
374            }
375            _ => {
376                if let Some(ref text) = event.text
377                    && !text.is_empty()
378                    && text.chars().all(|c| !c.is_control() || c == ' ')
379                {
380                    self.insert_text(text);
381                    return EventResult::Handled;
382                }
383                EventResult::Ignored
384            }
385        }
386    }
387
388    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
389        EventResult::Ignored
390    }
391
392    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
393        EventResult::Ignored
394    }
395
396    fn is_focused(&self) -> bool {
397        self.focused
398    }
399
400    fn set_focused(&mut self, focused: bool) {
401        self.focused = focused;
402        if focused {
403            self.cursor_position = self.text.len();
404        }
405    }
406
407    fn contains_point(&self, x: f32, y: f32) -> bool {
408        self.hit_test(x, y)
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Tests
414// ---------------------------------------------------------------------------
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn input_box_defaults() {
422        let ib = InputBox::default();
423        assert!(ib.text.is_empty());
424        assert!(ib.placeholder.is_none());
425        assert!(!ib.focused);
426        assert_eq!(ib.cursor_position, 0);
427    }
428
429    #[test]
430    fn input_box_set_text() {
431        let mut ib = InputBox::default();
432        ib.set_text("Hello");
433        assert_eq!(ib.text(), "Hello");
434        assert_eq!(ib.cursor_position, 5);
435    }
436
437    #[test]
438    fn input_box_insert_and_delete() {
439        let mut ib = InputBox::default();
440        ib.insert_text("abc");
441        assert_eq!(ib.text(), "abc");
442        assert_eq!(ib.cursor_position, 3);
443
444        ib.delete_char_before();
445        assert_eq!(ib.text(), "ab");
446        assert_eq!(ib.cursor_position, 2);
447
448        ib.move_cursor_left();
449        // cursor is now at position 1, before 'b'
450        ib.delete_char_after();
451        // 'b' was deleted, leaving "a"
452        assert_eq!(ib.text(), "a");
453        assert_eq!(ib.cursor_position, 1); // stayed at position 1
454    }
455
456    #[test]
457    fn input_box_cursor_movement() {
458        let mut ib = InputBox::default();
459        ib.set_text("Hello");
460        assert_eq!(ib.cursor_position, 5);
461
462        ib.move_cursor_home();
463        assert_eq!(ib.cursor_position, 0);
464
465        ib.move_cursor_end();
466        assert_eq!(ib.cursor_position, 5);
467
468        ib.move_cursor_left();
469        assert_eq!(ib.cursor_position, 4);
470
471        ib.move_cursor_right();
472        assert_eq!(ib.cursor_position, 5);
473    }
474
475    #[test]
476    fn input_box_password_display() {
477        let mut ib = InputBox::default();
478        ib.input_type = "password".to_string();
479        ib.set_text("secret");
480        let display = ib.display_text();
481        assert_eq!(display.chars().count(), 6);
482        assert!(display.chars().all(|c| c == '\u{2022}'));
483    }
484
485    #[test]
486    fn input_box_hit_test() {
487        let ib = InputBox::new(Rect {
488            x: 10.0,
489            y: 10.0,
490            w: 200.0,
491            h: 32.0,
492        });
493        assert!(ib.hit_test(50.0, 25.0));
494        assert!(!ib.hit_test(0.0, 0.0));
495    }
496
497    #[test]
498    fn input_box_disabled_no_edit() {
499        let mut ib = InputBox::default();
500        ib.disabled = true;
501        ib.insert_text("nope");
502        assert!(ib.text.is_empty());
503    }
504
505    #[test]
506    fn input_box_keyboard_typing() {
507        let mut ib = InputBox::default();
508        ib.focused = true;
509        let evt = KeyboardEvent {
510            key: KeyCode::Other(65),
511            state: ElementState::Pressed,
512            modifiers: Default::default(),
513            text: Some("a".to_string()),
514        };
515        assert_eq!(ib.handle_keyboard(&evt), EventResult::Handled);
516        assert_eq!(ib.text(), "a");
517    }
518}