Skip to main content

tui/components/
number_field.rs

1use crossterm::event::KeyCode;
2
3use crate::components::{Component, Event, ViewContext};
4use crate::line::Line;
5use crate::rendering::frame::Frame;
6
7/// Numeric input field supporting integers or floats.
8pub struct NumberField {
9    pub value: String,
10    pub integer_only: bool,
11}
12
13impl NumberField {
14    pub fn new(value: String, integer_only: bool) -> Self {
15        Self {
16            value,
17            integer_only,
18        }
19    }
20
21    pub fn to_json(&self) -> serde_json::Value {
22        if self.integer_only {
23            self.value
24                .parse::<i64>()
25                .map(serde_json::Value::from)
26                .unwrap_or(serde_json::Value::Null)
27        } else {
28            self.value
29                .parse::<f64>()
30                .map(serde_json::Value::from)
31                .unwrap_or(serde_json::Value::Null)
32        }
33    }
34}
35
36impl Component for NumberField {
37    type Message = ();
38
39    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
40        let Event::Key(key) = event else {
41            return None;
42        };
43        match key.code {
44            KeyCode::Char(c) => {
45                let accept = c.is_ascii_digit()
46                    || (c == '-' && self.value.is_empty())
47                    || (c == '.' && !self.integer_only && !self.value.contains('.'));
48                if accept {
49                    self.value.push(c);
50                }
51                Some(vec![])
52            }
53            KeyCode::Backspace => {
54                self.value.pop();
55                Some(vec![])
56            }
57            _ => None,
58        }
59    }
60
61    fn render(&mut self, context: &ViewContext) -> Frame {
62        Frame::new(self.render_field(context, true))
63    }
64}
65
66impl NumberField {
67    pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
68        let mut line = Line::new(&self.value);
69        if focused {
70            line.push_styled("▏", context.theme.primary());
71        }
72        vec![line]
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crossterm::event::{KeyEvent, KeyModifiers};
80
81    fn key(code: KeyCode) -> KeyEvent {
82        KeyEvent::new(code, KeyModifiers::NONE)
83    }
84
85    #[tokio::test]
86    async fn integer_accepts_digits_and_leading_minus() {
87        let mut field = NumberField::new(String::new(), true);
88        field.on_event(&Event::Key(key(KeyCode::Char('-')))).await;
89        field.on_event(&Event::Key(key(KeyCode::Char('4')))).await;
90        field.on_event(&Event::Key(key(KeyCode::Char('2')))).await;
91        assert_eq!(field.value, "-42");
92    }
93
94    #[tokio::test]
95    async fn integer_rejects_dot() {
96        let mut field = NumberField::new("1".to_string(), true);
97        field.on_event(&Event::Key(key(KeyCode::Char('.')))).await;
98        assert_eq!(field.value, "1");
99    }
100
101    #[tokio::test]
102    async fn float_accepts_single_dot() {
103        let mut field = NumberField::new(String::new(), false);
104        field.on_event(&Event::Key(key(KeyCode::Char('3')))).await;
105        field.on_event(&Event::Key(key(KeyCode::Char('.')))).await;
106        field.on_event(&Event::Key(key(KeyCode::Char('5')))).await;
107        assert_eq!(field.value, "3.5");
108    }
109
110    #[tokio::test]
111    async fn float_rejects_second_dot() {
112        let mut field = NumberField::new("1.2".to_string(), false);
113        field.on_event(&Event::Key(key(KeyCode::Char('.')))).await;
114        assert_eq!(field.value, "1.2");
115    }
116
117    #[tokio::test]
118    async fn minus_rejected_when_not_first() {
119        let mut field = NumberField::new("5".to_string(), true);
120        field.on_event(&Event::Key(key(KeyCode::Char('-')))).await;
121        assert_eq!(field.value, "5");
122    }
123
124    #[test]
125    fn to_json_integer() {
126        let field = NumberField::new("42".to_string(), true);
127        assert_eq!(field.to_json(), serde_json::json!(42));
128    }
129
130    #[test]
131    fn to_json_float() {
132        let field = NumberField::new("3.14".to_string(), false);
133        #[allow(clippy::approx_constant)]
134        let expected = serde_json::json!(3.14);
135        assert_eq!(field.to_json(), expected);
136    }
137
138    #[test]
139    fn to_json_empty_returns_null() {
140        let field = NumberField::new(String::new(), true);
141        assert_eq!(field.to_json(), serde_json::Value::Null);
142    }
143
144    #[tokio::test]
145    async fn backspace_removes_last() {
146        let mut field = NumberField::new("12".to_string(), true);
147        field.on_event(&Event::Key(key(KeyCode::Backspace))).await;
148        assert_eq!(field.value, "1");
149    }
150}