Skip to main content

jag_ui/widgets/
text_input.rs

1//! A reusable text input widget with blink-timer, placeholder, and flexible
2//! rendering.
3//!
4//! Used by popup menu filter, find bar, goto bar, and future bottom-panel
5//! inputs.
6
7use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
8use jag_surface::Canvas;
9
10use super::types::WidgetColors;
11
12const CARET_BLINK_RATE: f32 = 1.0;
13
14/// A reusable text input widget with blink-timer, placeholder, and flexible
15/// rendering.
16#[derive(Debug, Clone)]
17pub struct TextInput {
18    text: String,
19    caret_timer: f32,
20    placeholder: String,
21    text_size: f32,
22}
23
24impl TextInput {
25    pub fn new(placeholder: impl Into<String>, text_size: f32) -> Self {
26        Self {
27            text: String::new(),
28            caret_timer: 0.0,
29            placeholder: placeholder.into(),
30            text_size,
31        }
32    }
33
34    // -- State mutators (all reset caret) --
35
36    pub fn push_str(&mut self, s: &str) {
37        self.text.push_str(s);
38        self.reset_caret();
39    }
40
41    pub fn push_char(&mut self, c: char) {
42        self.text.push(c);
43        self.reset_caret();
44    }
45
46    pub fn pop_char(&mut self) -> Option<char> {
47        let c = self.text.pop();
48        self.reset_caret();
49        c
50    }
51
52    pub fn clear(&mut self) {
53        self.text.clear();
54        self.reset_caret();
55    }
56
57    pub fn set_text(&mut self, s: String) {
58        self.text = s;
59        self.reset_caret();
60    }
61
62    // -- Accessors --
63
64    pub fn text(&self) -> &str {
65        &self.text
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.text.is_empty()
70    }
71
72    // -- Blink --
73
74    pub fn update_blink(&mut self, dt: f32) {
75        self.caret_timer = (self.caret_timer + dt) % CARET_BLINK_RATE;
76    }
77
78    pub fn reset_caret(&mut self) {
79        self.caret_timer = 0.0;
80    }
81
82    fn caret_visible(&self) -> bool {
83        self.caret_timer < CARET_BLINK_RATE * 0.5
84    }
85
86    // -- Rendering --
87
88    /// Render the input field.
89    ///
90    /// - `corner_radius`: 0.0 for sharp (find/goto), >0 for rounded (popup).
91    /// - `display_prefix`: prepended to text on screen (e.g. `":"` for goto
92    ///   bar).
93    /// - `focused`: controls caret visibility.
94    #[allow(clippy::too_many_arguments)]
95    pub fn render(
96        &self,
97        canvas: &mut Canvas,
98        x: f32,
99        y: f32,
100        w: f32,
101        h: f32,
102        bg: ColorLinPremul,
103        border: Option<ColorLinPremul>,
104        focused: bool,
105        corner_radius: f32,
106        display_prefix: Option<&str>,
107        colors: &WidgetColors,
108        z: i32,
109    ) {
110        let pad = if corner_radius > 0.0 { 6.0 } else { 4.0 };
111
112        // Background
113        if corner_radius > 0.0 {
114            let rrect = RoundedRect {
115                rect: Rect { x, y, w, h },
116                radii: RoundedRadii {
117                    tl: corner_radius,
118                    tr: corner_radius,
119                    br: corner_radius,
120                    bl: corner_radius,
121                },
122            };
123            canvas.rounded_rect(rrect, Brush::Solid(bg), z);
124        } else {
125            canvas.fill_rect(x, y, w, h, Brush::Solid(bg), z);
126        }
127
128        // Border
129        if let Some(bc) = border {
130            if corner_radius > 0.0 {
131                let rrect = RoundedRect {
132                    rect: Rect { x, y, w, h },
133                    radii: RoundedRadii {
134                        tl: corner_radius,
135                        tr: corner_radius,
136                        br: corner_radius,
137                        bl: corner_radius,
138                    },
139                };
140                canvas.stroke_rounded_rect(rrect, 1.0, Brush::Solid(bc), z + 1);
141            } else {
142                let b = 1.0;
143                canvas.fill_rect(x, y, w, b, Brush::Solid(bc), z + 1);
144                canvas.fill_rect(x, y + h - b, w, b, Brush::Solid(bc), z + 1);
145                canvas.fill_rect(x, y, b, h, Brush::Solid(bc), z + 1);
146                canvas.fill_rect(x + w - b, y, b, h, Brush::Solid(bc), z + 1);
147            }
148        }
149
150        // Text / placeholder
151        let text_y = y + h / 2.0 + self.text_size * 0.35;
152        let text_x = x + pad;
153
154        if self.text.is_empty() && display_prefix.is_none() {
155            canvas.draw_text_run(
156                [text_x, text_y],
157                self.placeholder.clone(),
158                self.text_size,
159                colors.text_muted,
160                z + 2,
161            );
162        } else {
163            let display = match display_prefix {
164                Some(pre) => format!("{pre}{}", self.text),
165                None => self.text.clone(),
166            };
167            let color = if self.text.is_empty() {
168                colors.text_muted
169            } else {
170                colors.text
171            };
172            canvas.draw_text_run([text_x, text_y], display, self.text_size, color, z + 2);
173        }
174
175        // Caret
176        if focused && self.caret_visible() {
177            let display_text = match display_prefix {
178                Some(pre) if !self.text.is_empty() => format!("{pre}{}", self.text),
179                _ => self.text.clone(),
180            };
181            let text_w = if display_text.is_empty() {
182                0.0
183            } else {
184                canvas.measure_text_width(&display_text, self.text_size)
185            };
186            let caret_x = text_x + text_w;
187            let caret_y = y + 3.0;
188            let caret_h = h - 6.0;
189            canvas.fill_rect(
190                caret_x,
191                caret_y,
192                1.5,
193                caret_h,
194                Brush::Solid(colors.text),
195                z + 3,
196            );
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn new_input_is_empty() {
211        let input = TextInput::new("Search...", 14.0);
212        assert!(input.is_empty());
213        assert_eq!(input.text(), "");
214    }
215
216    #[test]
217    fn push_and_pop() {
218        let mut input = TextInput::new("", 14.0);
219        input.push_char('a');
220        input.push_char('b');
221        assert_eq!(input.text(), "ab");
222        assert_eq!(input.pop_char(), Some('b'));
223        assert_eq!(input.text(), "a");
224    }
225
226    #[test]
227    fn push_str_appends() {
228        let mut input = TextInput::new("", 14.0);
229        input.push_str("hello");
230        assert_eq!(input.text(), "hello");
231    }
232
233    #[test]
234    fn clear_empties() {
235        let mut input = TextInput::new("", 14.0);
236        input.push_str("data");
237        input.clear();
238        assert!(input.is_empty());
239    }
240
241    #[test]
242    fn set_text_replaces() {
243        let mut input = TextInput::new("", 14.0);
244        input.push_str("old");
245        input.set_text("new".to_string());
246        assert_eq!(input.text(), "new");
247    }
248
249    #[test]
250    fn caret_blink_cycle() {
251        let mut input = TextInput::new("", 14.0);
252        // Initially visible (timer = 0).
253        assert!(input.caret_visible());
254        // After half the blink rate, caret should be hidden.
255        input.update_blink(CARET_BLINK_RATE * 0.5);
256        assert!(!input.caret_visible());
257        // After a full cycle, visible again.
258        input.update_blink(CARET_BLINK_RATE * 0.5);
259        assert!(input.caret_visible());
260    }
261
262    #[test]
263    fn reset_caret_makes_visible() {
264        let mut input = TextInput::new("", 14.0);
265        input.update_blink(CARET_BLINK_RATE * 0.75);
266        assert!(!input.caret_visible());
267        input.reset_caret();
268        assert!(input.caret_visible());
269    }
270}