tui/components/
number_field.rs1use crossterm::event::KeyCode;
2
3use crate::components::{Component, Event, ViewContext};
4use crate::line::Line;
5use crate::rendering::frame::Frame;
6
7pub 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}