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 pub editing_bg: Color,
249}
250
251impl Default for TextInputColors {
252 fn default() -> Self {
253 Self {
254 label: Color::White,
255 text: Color::White,
256 border: Color::Gray,
257 placeholder: Color::DarkGray,
258 cursor: Color::Yellow,
259 focused: Color::Cyan,
260 disabled: Color::DarkGray,
261 editing_bg: Color::DarkGray,
262 }
263 }
264}
265
266impl TextInputColors {
267 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
269 Self {
270 label: theme.editor_fg,
271 text: theme.editor_fg,
272 border: theme.line_number_fg,
273 placeholder: theme.line_number_fg,
274 cursor: theme.cursor,
275 focused: theme.settings_selected_fg,
280 disabled: theme.line_number_fg,
281 editing_bg: theme.popup_selection_bg,
286 }
287 }
288
289 pub fn from_theme_disabled(theme: &crate::view::theme::Theme) -> Self {
293 Self {
294 label: theme.editor_fg,
295 text: theme.line_number_fg,
296 border: theme.line_number_fg,
297 placeholder: theme.line_number_fg,
298 cursor: theme.cursor,
299 focused: theme.settings_selected_fg,
300 disabled: theme.line_number_fg,
301 editing_bg: theme.popup_selection_bg,
302 }
303 }
304}
305
306#[derive(Debug, Clone, Copy, Default)]
308pub struct TextInputLayout {
309 pub input_area: Rect,
311 pub full_area: Rect,
313 pub cursor_pos: Option<(u16, u16)>,
315}
316
317impl TextInputLayout {
318 pub fn is_input(&self, x: u16, y: u16) -> bool {
320 x >= self.input_area.x
321 && x < self.input_area.x + self.input_area.width
322 && y >= self.input_area.y
323 && y < self.input_area.y + self.input_area.height
324 }
325
326 pub fn contains(&self, x: u16, y: u16) -> bool {
328 x >= self.full_area.x
329 && x < self.full_area.x + self.full_area.width
330 && y >= self.full_area.y
331 && y < self.full_area.y + self.full_area.height
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use ratatui::backend::TestBackend;
339 use ratatui::Terminal;
340
341 fn test_frame<F>(width: u16, height: u16, f: F)
342 where
343 F: FnOnce(&mut ratatui::Frame, Rect),
344 {
345 let backend = TestBackend::new(width, height);
346 let mut terminal = Terminal::new(backend).unwrap();
347 terminal
348 .draw(|frame| {
349 let area = Rect::new(0, 0, width, height);
350 f(frame, area);
351 })
352 .unwrap();
353 }
354
355 #[test]
356 fn test_arm_replace_on_type_replaces_value_on_first_char() {
357 let mut state = TextInputState::new("Width").with_value("30%");
358 state.arm_replace_on_type();
359 assert!(state.pending_replace_on_type);
360 state.insert('2');
361 assert_eq!(state.value, "2");
362 assert!(!state.pending_replace_on_type);
363 state.insert('4');
364 assert_eq!(state.value, "24");
365 }
366
367 #[test]
368 fn test_arm_replace_on_type_is_cancelled_by_cursor_movement() {
369 let mut state = TextInputState::new("Width").with_value("30%");
370 state.arm_replace_on_type();
371 state.move_left();
372 assert!(!state.pending_replace_on_type);
373 state.insert('x');
374 assert_eq!(state.value, "30x%");
375 }
376
377 #[test]
378 fn test_arm_replace_on_type_skips_when_empty() {
379 let mut state = TextInputState::new("Width");
380 state.arm_replace_on_type();
381 assert!(!state.pending_replace_on_type);
382 }
383
384 #[test]
385 fn test_arm_replace_on_type_backspace_clears_whole_value() {
386 let mut state = TextInputState::new("Width").with_value("30%");
387 state.arm_replace_on_type();
388 state.backspace();
389 assert_eq!(state.value, "");
390 assert!(!state.pending_replace_on_type);
391 }
392
393 #[test]
394 fn test_text_input_renders() {
395 test_frame(40, 1, |frame, area| {
396 let state = TextInputState::new("Name").with_value("John");
397 let colors = TextInputColors::default();
398 let layout = render_text_input(frame, area, &state, &colors, 20);
399
400 assert!(layout.input_area.width > 0);
401 });
402 }
403
404 #[test]
405 fn test_text_input_insert() {
406 let mut state = TextInputState::new("Test");
407 state.insert('a');
408 state.insert('b');
409 state.insert('c');
410 assert_eq!(state.value, "abc");
411 assert_eq!(state.cursor, 3);
412 }
413
414 #[test]
415 fn test_text_input_backspace() {
416 let mut state = TextInputState::new("Test").with_value("abc");
417 state.backspace();
418 assert_eq!(state.value, "ab");
419 assert_eq!(state.cursor, 2);
420 }
421
422 #[test]
423 fn test_text_input_cursor_movement() {
424 let mut state = TextInputState::new("Test").with_value("hello");
425 assert_eq!(state.cursor, 5);
426
427 state.move_left();
428 assert_eq!(state.cursor, 4);
429
430 state.move_home();
431 assert_eq!(state.cursor, 0);
432
433 state.move_right();
434 assert_eq!(state.cursor, 1);
435
436 state.move_end();
437 assert_eq!(state.cursor, 5);
438 }
439
440 #[test]
441 fn test_text_input_delete() {
442 let mut state = TextInputState::new("Test").with_value("abc");
443 state.move_home();
444 state.delete();
445 assert_eq!(state.value, "bc");
446 assert_eq!(state.cursor, 0);
447 }
448
449 #[test]
450 fn test_text_input_disabled() {
451 let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
452 state.insert('a');
453 assert_eq!(state.value, "");
454 }
455
456 #[test]
457 fn test_text_input_clear() {
458 let mut state = TextInputState::new("Test").with_value("hello");
459 state.clear();
460 assert_eq!(state.value, "");
461 assert_eq!(state.cursor, 0);
462 }
463
464 #[test]
465 fn test_text_input_multibyte_insert_and_backspace() {
466 let mut state = TextInputState::new("Test");
468 state.insert('©');
470 assert_eq!(state.value, "©");
471 assert_eq!(state.cursor, 2); state.backspace();
475 assert_eq!(state.value, "");
476 assert_eq!(state.cursor, 0);
477 }
478
479 #[test]
480 fn test_text_input_multibyte_cursor_movement() {
481 let mut state = TextInputState::new("Test").with_value("日本語");
482 assert_eq!(state.cursor, 9);
484
485 state.move_left();
486 assert_eq!(state.cursor, 6); state.move_left();
489 assert_eq!(state.cursor, 3);
490
491 state.move_right();
492 assert_eq!(state.cursor, 6);
493
494 state.move_home();
495 assert_eq!(state.cursor, 0);
496
497 state.move_right();
498 assert_eq!(state.cursor, 3); }
500
501 #[test]
502 fn test_text_input_multibyte_delete() {
503 let mut state = TextInputState::new("Test").with_value("a日b");
504 assert_eq!(state.cursor, 5);
506
507 state.move_home();
508 state.move_right(); assert_eq!(state.cursor, 1);
510
511 state.delete(); assert_eq!(state.value, "ab");
513 assert_eq!(state.cursor, 1);
514 }
515
516 #[test]
517 fn test_text_input_insert_between_multibyte() {
518 let mut state = TextInputState::new("Test").with_value("日語");
519 state.move_home();
520 state.move_right(); assert_eq!(state.cursor, 3);
522
523 state.insert('本');
524 assert_eq!(state.value, "日本語");
525 assert_eq!(state.cursor, 6);
526 }
527}