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 editing: bool,
41 pub validate_json: bool,
43 pub pending_replace_on_type: bool,
49}
50
51impl TextInputState {
52 pub fn new(label: impl Into<String>) -> Self {
54 Self {
55 value: String::new(),
56 cursor: 0,
57 label: label.into(),
58 placeholder: String::new(),
59 focus: FocusState::Normal,
60 editing: false,
61 validate_json: false,
62 pending_replace_on_type: false,
63 }
64 }
65
66 pub fn arm_replace_on_type(&mut self) {
69 self.pending_replace_on_type = !self.value.is_empty();
70 }
71
72 pub fn with_json_validation(mut self) -> Self {
74 self.validate_json = true;
75 self
76 }
77
78 pub fn is_valid(&self) -> bool {
80 if self.validate_json {
81 serde_json::from_str::<serde_json::Value>(&self.value).is_ok()
82 } else {
83 true
84 }
85 }
86
87 pub fn with_value(mut self, value: impl Into<String>) -> Self {
89 self.value = value.into();
90 self.cursor = self.value.len();
91 self
92 }
93
94 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
96 self.placeholder = placeholder.into();
97 self
98 }
99
100 pub fn with_focus(mut self, focus: FocusState) -> Self {
102 self.focus = focus;
103 self
104 }
105
106 pub fn is_enabled(&self) -> bool {
108 self.focus != FocusState::Disabled
109 }
110
111 pub fn insert(&mut self, c: char) {
113 if !self.is_enabled() {
114 return;
115 }
116 self.consume_pending_replace();
117 self.value.insert(self.cursor, c);
118 self.cursor += c.len_utf8();
119 }
120
121 pub fn insert_str(&mut self, s: &str) {
123 if !self.is_enabled() {
124 return;
125 }
126 self.consume_pending_replace();
127 self.value.insert_str(self.cursor, s);
128 self.cursor += s.len();
129 }
130
131 pub fn backspace(&mut self) {
133 if !self.is_enabled() || self.cursor == 0 {
134 return;
135 }
136 if self.consume_pending_replace() {
137 return;
139 }
140 let prev_boundary = self.value[..self.cursor]
142 .char_indices()
143 .next_back()
144 .map(|(i, _)| i)
145 .unwrap_or(0);
146 self.value.remove(prev_boundary);
147 self.cursor = prev_boundary;
148 }
149
150 fn consume_pending_replace(&mut self) -> bool {
153 if self.pending_replace_on_type {
154 self.value.clear();
155 self.cursor = 0;
156 self.pending_replace_on_type = false;
157 true
158 } else {
159 false
160 }
161 }
162
163 pub fn delete(&mut self) {
167 if !self.is_enabled() || self.cursor >= self.value.len() {
168 return;
169 }
170 if self.consume_pending_replace() {
171 return;
172 }
173 let next_boundary = grapheme::next_grapheme_boundary(&self.value, self.cursor);
174 self.value.drain(self.cursor..next_boundary);
175 }
176
177 pub fn move_left(&mut self) {
182 self.pending_replace_on_type = false;
183 if self.cursor > 0 {
184 self.cursor = grapheme::prev_grapheme_boundary(&self.value, self.cursor);
185 }
186 }
187
188 pub fn move_right(&mut self) {
193 self.pending_replace_on_type = false;
194 if self.cursor < self.value.len() {
195 self.cursor = grapheme::next_grapheme_boundary(&self.value, self.cursor);
196 }
197 }
198
199 pub fn move_home(&mut self) {
201 self.pending_replace_on_type = false;
202 self.cursor = 0;
203 }
204
205 pub fn move_end(&mut self) {
207 self.pending_replace_on_type = false;
208 self.cursor = self.value.len();
209 }
210
211 pub fn clear(&mut self) {
213 if self.is_enabled() {
214 self.value.clear();
215 self.cursor = 0;
216 }
217 }
218
219 pub fn set_value(&mut self, value: impl Into<String>) {
221 if self.is_enabled() {
222 self.value = value.into();
223 self.cursor = self.value.len();
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy)]
230pub struct TextInputColors {
231 pub label: Color,
233 pub text: Color,
235 pub border: Color,
237 pub placeholder: Color,
239 pub cursor: Color,
241 pub focused: Color,
243 pub disabled: Color,
245}
246
247impl Default for TextInputColors {
248 fn default() -> Self {
249 Self {
250 label: Color::White,
251 text: Color::White,
252 border: Color::Gray,
253 placeholder: Color::DarkGray,
254 cursor: Color::Yellow,
255 focused: Color::Cyan,
256 disabled: Color::DarkGray,
257 }
258 }
259}
260
261impl TextInputColors {
262 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
264 Self {
265 label: theme.editor_fg,
266 text: theme.editor_fg,
267 border: theme.line_number_fg,
268 placeholder: theme.line_number_fg,
269 cursor: theme.cursor,
270 focused: theme.settings_selected_fg,
275 disabled: theme.line_number_fg,
276 }
277 }
278
279 pub fn from_theme_disabled(theme: &crate::view::theme::Theme) -> Self {
283 Self {
284 label: theme.editor_fg,
285 text: theme.line_number_fg,
286 border: theme.line_number_fg,
287 placeholder: theme.line_number_fg,
288 cursor: theme.cursor,
289 focused: theme.settings_selected_fg,
290 disabled: theme.line_number_fg,
291 }
292 }
293}
294
295#[derive(Debug, Clone, Copy, Default)]
297pub struct TextInputLayout {
298 pub input_area: Rect,
300 pub full_area: Rect,
302 pub cursor_pos: Option<(u16, u16)>,
304}
305
306impl TextInputLayout {
307 pub fn is_input(&self, x: u16, y: u16) -> bool {
309 x >= self.input_area.x
310 && x < self.input_area.x + self.input_area.width
311 && y >= self.input_area.y
312 && y < self.input_area.y + self.input_area.height
313 }
314
315 pub fn contains(&self, x: u16, y: u16) -> bool {
317 x >= self.full_area.x
318 && x < self.full_area.x + self.full_area.width
319 && y >= self.full_area.y
320 && y < self.full_area.y + self.full_area.height
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use ratatui::backend::TestBackend;
328 use ratatui::Terminal;
329
330 fn test_frame<F>(width: u16, height: u16, f: F)
331 where
332 F: FnOnce(&mut ratatui::Frame, Rect),
333 {
334 let backend = TestBackend::new(width, height);
335 let mut terminal = Terminal::new(backend).unwrap();
336 terminal
337 .draw(|frame| {
338 let area = Rect::new(0, 0, width, height);
339 f(frame, area);
340 })
341 .unwrap();
342 }
343
344 #[test]
345 fn test_arm_replace_on_type_replaces_value_on_first_char() {
346 let mut state = TextInputState::new("Width").with_value("30%");
347 state.arm_replace_on_type();
348 assert!(state.pending_replace_on_type);
349 state.insert('2');
350 assert_eq!(state.value, "2");
351 assert!(!state.pending_replace_on_type);
352 state.insert('4');
353 assert_eq!(state.value, "24");
354 }
355
356 #[test]
357 fn test_arm_replace_on_type_is_cancelled_by_cursor_movement() {
358 let mut state = TextInputState::new("Width").with_value("30%");
359 state.arm_replace_on_type();
360 state.move_left();
361 assert!(!state.pending_replace_on_type);
362 state.insert('x');
363 assert_eq!(state.value, "30x%");
364 }
365
366 #[test]
367 fn test_arm_replace_on_type_skips_when_empty() {
368 let mut state = TextInputState::new("Width");
369 state.arm_replace_on_type();
370 assert!(!state.pending_replace_on_type);
371 }
372
373 #[test]
374 fn test_arm_replace_on_type_backspace_clears_whole_value() {
375 let mut state = TextInputState::new("Width").with_value("30%");
376 state.arm_replace_on_type();
377 state.backspace();
378 assert_eq!(state.value, "");
379 assert!(!state.pending_replace_on_type);
380 }
381
382 #[test]
383 fn test_text_input_renders() {
384 test_frame(40, 1, |frame, area| {
385 let state = TextInputState::new("Name").with_value("John");
386 let colors = TextInputColors::default();
387 let layout = render_text_input(frame, area, &state, &colors, 20);
388
389 assert!(layout.input_area.width > 0);
390 });
391 }
392
393 #[test]
394 fn test_text_input_insert() {
395 let mut state = TextInputState::new("Test");
396 state.insert('a');
397 state.insert('b');
398 state.insert('c');
399 assert_eq!(state.value, "abc");
400 assert_eq!(state.cursor, 3);
401 }
402
403 #[test]
404 fn test_text_input_backspace() {
405 let mut state = TextInputState::new("Test").with_value("abc");
406 state.backspace();
407 assert_eq!(state.value, "ab");
408 assert_eq!(state.cursor, 2);
409 }
410
411 #[test]
412 fn test_text_input_cursor_movement() {
413 let mut state = TextInputState::new("Test").with_value("hello");
414 assert_eq!(state.cursor, 5);
415
416 state.move_left();
417 assert_eq!(state.cursor, 4);
418
419 state.move_home();
420 assert_eq!(state.cursor, 0);
421
422 state.move_right();
423 assert_eq!(state.cursor, 1);
424
425 state.move_end();
426 assert_eq!(state.cursor, 5);
427 }
428
429 #[test]
430 fn test_text_input_delete() {
431 let mut state = TextInputState::new("Test").with_value("abc");
432 state.move_home();
433 state.delete();
434 assert_eq!(state.value, "bc");
435 assert_eq!(state.cursor, 0);
436 }
437
438 #[test]
439 fn test_text_input_disabled() {
440 let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
441 state.insert('a');
442 assert_eq!(state.value, "");
443 }
444
445 #[test]
446 fn test_text_input_clear() {
447 let mut state = TextInputState::new("Test").with_value("hello");
448 state.clear();
449 assert_eq!(state.value, "");
450 assert_eq!(state.cursor, 0);
451 }
452
453 #[test]
454 fn test_text_input_multibyte_insert_and_backspace() {
455 let mut state = TextInputState::new("Test");
457 state.insert('©');
459 assert_eq!(state.value, "©");
460 assert_eq!(state.cursor, 2); state.backspace();
464 assert_eq!(state.value, "");
465 assert_eq!(state.cursor, 0);
466 }
467
468 #[test]
469 fn test_text_input_multibyte_cursor_movement() {
470 let mut state = TextInputState::new("Test").with_value("日本語");
471 assert_eq!(state.cursor, 9);
473
474 state.move_left();
475 assert_eq!(state.cursor, 6); state.move_left();
478 assert_eq!(state.cursor, 3);
479
480 state.move_right();
481 assert_eq!(state.cursor, 6);
482
483 state.move_home();
484 assert_eq!(state.cursor, 0);
485
486 state.move_right();
487 assert_eq!(state.cursor, 3); }
489
490 #[test]
491 fn test_text_input_multibyte_delete() {
492 let mut state = TextInputState::new("Test").with_value("a日b");
493 assert_eq!(state.cursor, 5);
495
496 state.move_home();
497 state.move_right(); assert_eq!(state.cursor, 1);
499
500 state.delete(); assert_eq!(state.value, "ab");
502 assert_eq!(state.cursor, 1);
503 }
504
505 #[test]
506 fn test_text_input_insert_between_multibyte() {
507 let mut state = TextInputState::new("Test").with_value("日語");
508 state.move_home();
509 state.move_right(); assert_eq!(state.cursor, 3);
511
512 state.insert('本');
513 assert_eq!(state.value, "日本語");
514 assert_eq!(state.cursor, 6);
515 }
516}