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