fresh/view/controls/text_input/
mod.rs1mod input;
12mod render;
13
14use crate::primitives::grapheme;
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17
18pub use input::TextInputEvent;
19pub use render::{render_text_input, render_text_input_aligned};
20
21use super::FocusState;
22
23#[derive(Debug, Clone)]
25pub struct TextInputState {
26 pub value: String,
28 pub cursor: usize,
30 pub label: String,
32 pub placeholder: String,
34 pub focus: FocusState,
36 pub validate_json: bool,
38}
39
40impl TextInputState {
41 pub fn new(label: impl Into<String>) -> Self {
43 Self {
44 value: String::new(),
45 cursor: 0,
46 label: label.into(),
47 placeholder: String::new(),
48 focus: FocusState::Normal,
49 validate_json: false,
50 }
51 }
52
53 pub fn with_json_validation(mut self) -> Self {
55 self.validate_json = true;
56 self
57 }
58
59 pub fn is_valid(&self) -> bool {
61 if self.validate_json {
62 serde_json::from_str::<serde_json::Value>(&self.value).is_ok()
63 } else {
64 true
65 }
66 }
67
68 pub fn with_value(mut self, value: impl Into<String>) -> Self {
70 self.value = value.into();
71 self.cursor = self.value.len();
72 self
73 }
74
75 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
77 self.placeholder = placeholder.into();
78 self
79 }
80
81 pub fn with_focus(mut self, focus: FocusState) -> Self {
83 self.focus = focus;
84 self
85 }
86
87 pub fn is_enabled(&self) -> bool {
89 self.focus != FocusState::Disabled
90 }
91
92 pub fn insert(&mut self, c: char) {
94 if !self.is_enabled() {
95 return;
96 }
97 self.value.insert(self.cursor, c);
98 self.cursor += c.len_utf8();
99 }
100
101 pub fn insert_str(&mut self, s: &str) {
103 if !self.is_enabled() {
104 return;
105 }
106 self.value.insert_str(self.cursor, s);
107 self.cursor += s.len();
108 }
109
110 pub fn backspace(&mut self) {
112 if !self.is_enabled() || self.cursor == 0 {
113 return;
114 }
115 let prev_boundary = self.value[..self.cursor]
117 .char_indices()
118 .next_back()
119 .map(|(i, _)| i)
120 .unwrap_or(0);
121 self.value.remove(prev_boundary);
122 self.cursor = prev_boundary;
123 }
124
125 pub fn delete(&mut self) {
129 if !self.is_enabled() || self.cursor >= self.value.len() {
130 return;
131 }
132 let next_boundary = grapheme::next_grapheme_boundary(&self.value, self.cursor);
133 self.value.drain(self.cursor..next_boundary);
134 }
135
136 pub fn move_left(&mut self) {
141 if self.cursor > 0 {
142 self.cursor = grapheme::prev_grapheme_boundary(&self.value, self.cursor);
143 }
144 }
145
146 pub fn move_right(&mut self) {
151 if self.cursor < self.value.len() {
152 self.cursor = grapheme::next_grapheme_boundary(&self.value, self.cursor);
153 }
154 }
155
156 pub fn move_home(&mut self) {
158 self.cursor = 0;
159 }
160
161 pub fn move_end(&mut self) {
163 self.cursor = self.value.len();
164 }
165
166 pub fn clear(&mut self) {
168 if self.is_enabled() {
169 self.value.clear();
170 self.cursor = 0;
171 }
172 }
173
174 pub fn set_value(&mut self, value: impl Into<String>) {
176 if self.is_enabled() {
177 self.value = value.into();
178 self.cursor = self.value.len();
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy)]
185pub struct TextInputColors {
186 pub label: Color,
188 pub text: Color,
190 pub border: Color,
192 pub placeholder: Color,
194 pub cursor: Color,
196 pub focused: Color,
198 pub disabled: Color,
200}
201
202impl Default for TextInputColors {
203 fn default() -> Self {
204 Self {
205 label: Color::White,
206 text: Color::White,
207 border: Color::Gray,
208 placeholder: Color::DarkGray,
209 cursor: Color::Yellow,
210 focused: Color::Cyan,
211 disabled: Color::DarkGray,
212 }
213 }
214}
215
216impl TextInputColors {
217 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
219 Self {
220 label: theme.editor_fg,
221 text: theme.editor_fg,
222 border: theme.line_number_fg,
223 placeholder: theme.line_number_fg,
224 cursor: theme.cursor,
225 focused: theme.selection_bg,
226 disabled: theme.line_number_fg,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, Default)]
233pub struct TextInputLayout {
234 pub input_area: Rect,
236 pub full_area: Rect,
238 pub cursor_pos: Option<(u16, u16)>,
240}
241
242impl TextInputLayout {
243 pub fn is_input(&self, x: u16, y: u16) -> bool {
245 x >= self.input_area.x
246 && x < self.input_area.x + self.input_area.width
247 && y >= self.input_area.y
248 && y < self.input_area.y + self.input_area.height
249 }
250
251 pub fn contains(&self, x: u16, y: u16) -> bool {
253 x >= self.full_area.x
254 && x < self.full_area.x + self.full_area.width
255 && y >= self.full_area.y
256 && y < self.full_area.y + self.full_area.height
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use ratatui::backend::TestBackend;
264 use ratatui::Terminal;
265
266 fn test_frame<F>(width: u16, height: u16, f: F)
267 where
268 F: FnOnce(&mut ratatui::Frame, Rect),
269 {
270 let backend = TestBackend::new(width, height);
271 let mut terminal = Terminal::new(backend).unwrap();
272 terminal
273 .draw(|frame| {
274 let area = Rect::new(0, 0, width, height);
275 f(frame, area);
276 })
277 .unwrap();
278 }
279
280 #[test]
281 fn test_text_input_renders() {
282 test_frame(40, 1, |frame, area| {
283 let state = TextInputState::new("Name").with_value("John");
284 let colors = TextInputColors::default();
285 let layout = render_text_input(frame, area, &state, &colors, 20);
286
287 assert!(layout.input_area.width > 0);
288 });
289 }
290
291 #[test]
292 fn test_text_input_insert() {
293 let mut state = TextInputState::new("Test");
294 state.insert('a');
295 state.insert('b');
296 state.insert('c');
297 assert_eq!(state.value, "abc");
298 assert_eq!(state.cursor, 3);
299 }
300
301 #[test]
302 fn test_text_input_backspace() {
303 let mut state = TextInputState::new("Test").with_value("abc");
304 state.backspace();
305 assert_eq!(state.value, "ab");
306 assert_eq!(state.cursor, 2);
307 }
308
309 #[test]
310 fn test_text_input_cursor_movement() {
311 let mut state = TextInputState::new("Test").with_value("hello");
312 assert_eq!(state.cursor, 5);
313
314 state.move_left();
315 assert_eq!(state.cursor, 4);
316
317 state.move_home();
318 assert_eq!(state.cursor, 0);
319
320 state.move_right();
321 assert_eq!(state.cursor, 1);
322
323 state.move_end();
324 assert_eq!(state.cursor, 5);
325 }
326
327 #[test]
328 fn test_text_input_delete() {
329 let mut state = TextInputState::new("Test").with_value("abc");
330 state.move_home();
331 state.delete();
332 assert_eq!(state.value, "bc");
333 assert_eq!(state.cursor, 0);
334 }
335
336 #[test]
337 fn test_text_input_disabled() {
338 let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
339 state.insert('a');
340 assert_eq!(state.value, "");
341 }
342
343 #[test]
344 fn test_text_input_clear() {
345 let mut state = TextInputState::new("Test").with_value("hello");
346 state.clear();
347 assert_eq!(state.value, "");
348 assert_eq!(state.cursor, 0);
349 }
350
351 #[test]
352 fn test_text_input_multibyte_insert_and_backspace() {
353 let mut state = TextInputState::new("Test");
355 state.insert('©');
357 assert_eq!(state.value, "©");
358 assert_eq!(state.cursor, 2); state.backspace();
362 assert_eq!(state.value, "");
363 assert_eq!(state.cursor, 0);
364 }
365
366 #[test]
367 fn test_text_input_multibyte_cursor_movement() {
368 let mut state = TextInputState::new("Test").with_value("日本語");
369 assert_eq!(state.cursor, 9);
371
372 state.move_left();
373 assert_eq!(state.cursor, 6); state.move_left();
376 assert_eq!(state.cursor, 3);
377
378 state.move_right();
379 assert_eq!(state.cursor, 6);
380
381 state.move_home();
382 assert_eq!(state.cursor, 0);
383
384 state.move_right();
385 assert_eq!(state.cursor, 3); }
387
388 #[test]
389 fn test_text_input_multibyte_delete() {
390 let mut state = TextInputState::new("Test").with_value("a日b");
391 assert_eq!(state.cursor, 5);
393
394 state.move_home();
395 state.move_right(); assert_eq!(state.cursor, 1);
397
398 state.delete(); assert_eq!(state.value, "ab");
400 assert_eq!(state.cursor, 1);
401 }
402
403 #[test]
404 fn test_text_input_insert_between_multibyte() {
405 let mut state = TextInputState::new("Test").with_value("日語");
406 state.move_home();
407 state.move_right(); assert_eq!(state.cursor, 3);
409
410 state.insert('本');
411 assert_eq!(state.value, "日本語");
412 assert_eq!(state.cursor, 6);
413 }
414}