ratatui_interact/components/
input.rs1use ratatui::{
27 Frame,
28 layout::Rect,
29 style::{Color, Style},
30 text::{Line, Span},
31 widgets::{Block, Borders, Paragraph},
32};
33
34use crate::traits::{ClickRegion, FocusId};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum InputAction {
39 Focus,
41}
42
43#[derive(Debug, Clone)]
45pub struct InputState {
46 pub text: String,
48 pub cursor_pos: usize,
50 pub focused: bool,
52 pub enabled: bool,
54 pub scroll_offset: usize,
56}
57
58impl Default for InputState {
59 fn default() -> Self {
60 Self {
61 text: String::new(),
62 cursor_pos: 0,
63 focused: false,
64 enabled: true,
65 scroll_offset: 0,
66 }
67 }
68}
69
70impl InputState {
71 pub fn new(text: impl Into<String>) -> Self {
75 let text = text.into();
76 let cursor_pos = text.chars().count();
77 Self {
78 text,
79 cursor_pos,
80 focused: false,
81 enabled: true,
82 scroll_offset: 0,
83 }
84 }
85
86 pub fn empty() -> Self {
88 Self::default()
89 }
90
91 pub fn insert_char(&mut self, c: char) {
93 if !self.enabled {
94 return;
95 }
96 let byte_pos = self.char_to_byte_index(self.cursor_pos);
97 self.text.insert(byte_pos, c);
98 self.cursor_pos += 1;
99 }
100
101 pub fn insert_str(&mut self, s: &str) {
103 if !self.enabled {
104 return;
105 }
106 let byte_pos = self.char_to_byte_index(self.cursor_pos);
107 self.text.insert_str(byte_pos, s);
108 self.cursor_pos += s.chars().count();
109 }
110
111 pub fn delete_char_backward(&mut self) -> bool {
115 if !self.enabled || self.cursor_pos == 0 {
116 return false;
117 }
118
119 self.cursor_pos -= 1;
120 let byte_pos = self.char_to_byte_index(self.cursor_pos);
121 if let Some(c) = self.text[byte_pos..].chars().next() {
122 self.text
123 .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
124 return true;
125 }
126 false
127 }
128
129 pub fn delete_char_forward(&mut self) -> bool {
133 if !self.enabled {
134 return false;
135 }
136
137 let byte_pos = self.char_to_byte_index(self.cursor_pos);
138 if byte_pos < self.text.len() {
139 if let Some(c) = self.text[byte_pos..].chars().next() {
140 self.text
141 .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
142 return true;
143 }
144 }
145 false
146 }
147
148 pub fn delete_word_backward(&mut self) -> bool {
152 if !self.enabled || self.cursor_pos == 0 {
153 return false;
154 }
155
156 let start_pos = self.cursor_pos;
157
158 while self.cursor_pos > 0 {
160 let prev_char = self.char_at(self.cursor_pos - 1);
161 if prev_char.map(|c| c.is_whitespace()).unwrap_or(false) {
162 self.cursor_pos -= 1;
163 } else {
164 break;
165 }
166 }
167
168 while self.cursor_pos > 0 {
170 let prev_char = self.char_at(self.cursor_pos - 1);
171 if prev_char.map(|c| !c.is_whitespace()).unwrap_or(false) {
172 self.delete_char_backward();
173 } else {
174 break;
175 }
176 }
177
178 start_pos != self.cursor_pos
179 }
180
181 pub fn move_left(&mut self) {
183 if self.cursor_pos > 0 {
184 self.cursor_pos -= 1;
185 }
186 }
187
188 pub fn move_right(&mut self) {
190 let max = self.text.chars().count();
191 if self.cursor_pos < max {
192 self.cursor_pos += 1;
193 }
194 }
195
196 pub fn move_home(&mut self) {
198 self.cursor_pos = 0;
199 }
200
201 pub fn move_end(&mut self) {
203 self.cursor_pos = self.text.chars().count();
204 }
205
206 pub fn move_word_left(&mut self) {
208 if self.cursor_pos == 0 {
209 return;
210 }
211
212 while self.cursor_pos > 0 {
214 if let Some(c) = self.char_at(self.cursor_pos - 1) {
215 if c.is_whitespace() {
216 self.cursor_pos -= 1;
217 } else {
218 break;
219 }
220 } else {
221 break;
222 }
223 }
224
225 while self.cursor_pos > 0 {
227 if let Some(c) = self.char_at(self.cursor_pos - 1) {
228 if !c.is_whitespace() {
229 self.cursor_pos -= 1;
230 } else {
231 break;
232 }
233 } else {
234 break;
235 }
236 }
237 }
238
239 pub fn move_word_right(&mut self) {
241 let max = self.text.chars().count();
242 if self.cursor_pos >= max {
243 return;
244 }
245
246 while self.cursor_pos < max {
248 if let Some(c) = self.char_at(self.cursor_pos) {
249 if !c.is_whitespace() {
250 self.cursor_pos += 1;
251 } else {
252 break;
253 }
254 } else {
255 break;
256 }
257 }
258
259 while self.cursor_pos < max {
261 if let Some(c) = self.char_at(self.cursor_pos) {
262 if c.is_whitespace() {
263 self.cursor_pos += 1;
264 } else {
265 break;
266 }
267 } else {
268 break;
269 }
270 }
271 }
272
273 pub fn clear(&mut self) {
275 self.text.clear();
276 self.cursor_pos = 0;
277 self.scroll_offset = 0;
278 }
279
280 pub fn set_text(&mut self, text: impl Into<String>) {
284 self.text = text.into();
285 self.cursor_pos = self.text.chars().count();
286 self.scroll_offset = 0;
287 }
288
289 fn char_at(&self, index: usize) -> Option<char> {
291 self.text.chars().nth(index)
292 }
293
294 fn char_to_byte_index(&self, char_idx: usize) -> usize {
296 self.text
297 .char_indices()
298 .nth(char_idx)
299 .map(|(i, _)| i)
300 .unwrap_or(self.text.len())
301 }
302
303 pub fn text_before_cursor(&self) -> &str {
305 let byte_pos = self.char_to_byte_index(self.cursor_pos);
306 &self.text[..byte_pos]
307 }
308
309 pub fn text_after_cursor(&self) -> &str {
311 let byte_pos = self.char_to_byte_index(self.cursor_pos);
312 &self.text[byte_pos..]
313 }
314
315 pub fn is_empty(&self) -> bool {
317 self.text.is_empty()
318 }
319
320 pub fn len(&self) -> usize {
322 self.text.chars().count()
323 }
324
325 pub fn text(&self) -> &str {
327 &self.text
328 }
329}
330
331#[derive(Debug, Clone)]
333pub struct InputStyle {
334 pub focused_border: Color,
336 pub unfocused_border: Color,
338 pub disabled_border: Color,
340 pub text_fg: Color,
342 pub cursor_fg: Color,
344 pub placeholder_fg: Color,
346}
347
348impl Default for InputStyle {
349 fn default() -> Self {
350 Self {
351 focused_border: Color::Yellow,
352 unfocused_border: Color::Gray,
353 disabled_border: Color::DarkGray,
354 text_fg: Color::White,
355 cursor_fg: Color::Yellow,
356 placeholder_fg: Color::DarkGray,
357 }
358 }
359}
360
361impl InputStyle {
362 pub fn focused_border(mut self, color: Color) -> Self {
364 self.focused_border = color;
365 self
366 }
367
368 pub fn unfocused_border(mut self, color: Color) -> Self {
370 self.unfocused_border = color;
371 self
372 }
373
374 pub fn text_fg(mut self, color: Color) -> Self {
376 self.text_fg = color;
377 self
378 }
379
380 pub fn cursor_fg(mut self, color: Color) -> Self {
382 self.cursor_fg = color;
383 self
384 }
385
386 pub fn placeholder_fg(mut self, color: Color) -> Self {
388 self.placeholder_fg = color;
389 self
390 }
391}
392
393pub struct Input<'a> {
397 label: Option<&'a str>,
398 placeholder: Option<&'a str>,
399 state: &'a InputState,
400 style: InputStyle,
401 focus_id: FocusId,
402 with_border: bool,
403}
404
405impl<'a> Input<'a> {
406 pub fn new(state: &'a InputState) -> Self {
412 Self {
413 label: None,
414 placeholder: None,
415 state,
416 style: InputStyle::default(),
417 focus_id: FocusId::default(),
418 with_border: true,
419 }
420 }
421
422 pub fn label(mut self, label: &'a str) -> Self {
424 self.label = Some(label);
425 self
426 }
427
428 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
430 self.placeholder = Some(placeholder);
431 self
432 }
433
434 pub fn style(mut self, style: InputStyle) -> Self {
436 self.style = style;
437 self
438 }
439
440 pub fn focus_id(mut self, id: FocusId) -> Self {
442 self.focus_id = id;
443 self
444 }
445
446 pub fn with_border(mut self, with_border: bool) -> Self {
448 self.with_border = with_border;
449 self
450 }
451
452 pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<InputAction> {
454 let border_color = if !self.state.enabled {
455 self.style.disabled_border
456 } else if self.state.focused {
457 self.style.focused_border
458 } else {
459 self.style.unfocused_border
460 };
461
462 let block = if self.with_border {
463 let mut block = Block::default()
464 .borders(Borders::ALL)
465 .border_style(Style::default().fg(border_color));
466 if let Some(label) = self.label {
467 block = block.title(format!(" {} ", label));
468 }
469 Some(block)
470 } else {
471 None
472 };
473
474 let inner_area = if let Some(ref b) = block {
475 b.inner(area)
476 } else {
477 area
478 };
479
480 let display_line = if self.state.text.is_empty() {
482 if let Some(placeholder) = self.placeholder {
483 Line::from(Span::styled(
484 placeholder,
485 Style::default().fg(self.style.placeholder_fg),
486 ))
487 } else if self.state.focused {
488 Line::from(Span::styled("│", Style::default().fg(self.style.cursor_fg)))
490 } else {
491 Line::from("")
492 }
493 } else {
494 let before = self.state.text_before_cursor();
495 let after = self.state.text_after_cursor();
496
497 let mut spans = vec![Span::styled(
498 before.to_string(),
499 Style::default().fg(self.style.text_fg),
500 )];
501
502 if self.state.focused {
503 spans.push(Span::styled("│", Style::default().fg(self.style.cursor_fg)));
504 }
505
506 spans.push(Span::styled(
507 after.to_string(),
508 Style::default().fg(self.style.text_fg),
509 ));
510
511 Line::from(spans)
512 };
513
514 let paragraph = Paragraph::new(display_line);
515
516 if let Some(block) = block {
517 frame.render_widget(block, area);
518 }
519 frame.render_widget(paragraph, inner_area);
520
521 ClickRegion::new(area, InputAction::Focus)
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_state_default() {
531 let state = InputState::default();
532 assert!(state.text.is_empty());
533 assert_eq!(state.cursor_pos, 0);
534 assert!(!state.focused);
535 assert!(state.enabled);
536 }
537
538 #[test]
539 fn test_state_new() {
540 let state = InputState::new("Hello");
541 assert_eq!(state.text, "Hello");
542 assert_eq!(state.cursor_pos, 5); }
544
545 #[test]
546 fn test_insert_char() {
547 let mut state = InputState::new("Hello");
548 state.insert_char('!');
549 assert_eq!(state.text, "Hello!");
550 assert_eq!(state.cursor_pos, 6);
551 }
552
553 #[test]
554 fn test_insert_char_middle() {
555 let mut state = InputState::new("Hllo");
556 state.cursor_pos = 1;
557 state.insert_char('e');
558 assert_eq!(state.text, "Hello");
559 assert_eq!(state.cursor_pos, 2);
560 }
561
562 #[test]
563 fn test_insert_str() {
564 let mut state = InputState::new("Hello");
565 state.insert_str(" World");
566 assert_eq!(state.text, "Hello World");
567 }
568
569 #[test]
570 fn test_delete_char_backward() {
571 let mut state = InputState::new("Hello");
572 assert!(state.delete_char_backward());
573 assert_eq!(state.text, "Hell");
574 assert_eq!(state.cursor_pos, 4);
575 }
576
577 #[test]
578 fn test_delete_char_backward_at_start() {
579 let mut state = InputState::new("Hello");
580 state.cursor_pos = 0;
581 assert!(!state.delete_char_backward());
582 assert_eq!(state.text, "Hello");
583 }
584
585 #[test]
586 fn test_delete_char_forward() {
587 let mut state = InputState::new("Hello");
588 state.cursor_pos = 0;
589 assert!(state.delete_char_forward());
590 assert_eq!(state.text, "ello");
591 }
592
593 #[test]
594 fn test_delete_char_forward_at_end() {
595 let mut state = InputState::new("Hello");
596 assert!(!state.delete_char_forward());
597 assert_eq!(state.text, "Hello");
598 }
599
600 #[test]
601 fn test_move_cursor() {
602 let mut state = InputState::new("Hello");
603 assert_eq!(state.cursor_pos, 5);
604
605 state.move_left();
606 assert_eq!(state.cursor_pos, 4);
607
608 state.move_right();
609 assert_eq!(state.cursor_pos, 5);
610
611 state.move_home();
612 assert_eq!(state.cursor_pos, 0);
613
614 state.move_end();
615 assert_eq!(state.cursor_pos, 5);
616 }
617
618 #[test]
619 fn test_move_cursor_bounds() {
620 let mut state = InputState::new("Hi");
621
622 state.move_home();
623 state.move_left(); assert_eq!(state.cursor_pos, 0);
625
626 state.move_end();
627 state.move_right(); assert_eq!(state.cursor_pos, 2);
629 }
630
631 #[test]
632 fn test_move_word() {
633 let mut state = InputState::new("Hello World Test");
634
635 state.move_home();
636 state.move_word_right();
637 assert_eq!(state.cursor_pos, 6); state.move_word_right();
640 assert_eq!(state.cursor_pos, 12); state.move_word_left();
643 assert_eq!(state.cursor_pos, 6); }
645
646 #[test]
647 fn test_clear() {
648 let mut state = InputState::new("Hello");
649 state.clear();
650 assert!(state.text.is_empty());
651 assert_eq!(state.cursor_pos, 0);
652 }
653
654 #[test]
655 fn test_set_text() {
656 let mut state = InputState::new("Hello");
657 state.set_text("World");
658 assert_eq!(state.text, "World");
659 assert_eq!(state.cursor_pos, 5);
660 }
661
662 #[test]
663 fn test_text_before_after_cursor() {
664 let mut state = InputState::new("Hello");
665 state.cursor_pos = 2;
666
667 assert_eq!(state.text_before_cursor(), "He");
668 assert_eq!(state.text_after_cursor(), "llo");
669 }
670
671 #[test]
672 fn test_unicode_handling() {
673 let mut state = InputState::new("你好");
674 assert_eq!(state.cursor_pos, 2); state.move_left();
677 assert_eq!(state.cursor_pos, 1);
678
679 state.insert_char('世');
680 assert_eq!(state.text, "你世好");
681 }
682
683 #[test]
684 fn test_emoji_handling() {
685 let mut state = InputState::new("Hi 👋");
686 assert_eq!(state.len(), 4); state.delete_char_backward();
689 assert_eq!(state.text, "Hi ");
690 }
691
692 #[test]
693 fn test_disabled_input() {
694 let mut state = InputState::new("Hello");
695 state.enabled = false;
696
697 state.insert_char('!');
698 assert_eq!(state.text, "Hello"); assert!(!state.delete_char_backward());
701 assert_eq!(state.text, "Hello"); }
703
704 #[test]
705 fn test_is_empty_and_len() {
706 let state = InputState::empty();
707 assert!(state.is_empty());
708 assert_eq!(state.len(), 0);
709
710 let state = InputState::new("Test");
711 assert!(!state.is_empty());
712 assert_eq!(state.len(), 4);
713 }
714
715 #[test]
716 fn test_input_style_builder() {
717 let style = InputStyle::default()
718 .focused_border(Color::Cyan)
719 .text_fg(Color::Green);
720
721 assert_eq!(style.focused_border, Color::Cyan);
722 assert_eq!(style.text_fg, Color::Green);
723 }
724}