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 { 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}