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
393impl From<&crate::theme::Theme> for InputStyle {
394 fn from(theme: &crate::theme::Theme) -> Self {
395 let p = &theme.palette;
396 Self {
397 focused_border: p.border_focused,
398 unfocused_border: p.border,
399 disabled_border: p.border_disabled,
400 text_fg: p.text,
401 cursor_fg: p.primary,
402 placeholder_fg: p.text_placeholder,
403 }
404 }
405}
406
407pub struct Input<'a> {
411 label: Option<&'a str>,
412 placeholder: Option<&'a str>,
413 state: &'a InputState,
414 style: InputStyle,
415 focus_id: FocusId,
416 with_border: bool,
417}
418
419impl<'a> Input<'a> {
420 pub fn new(state: &'a InputState) -> Self {
426 Self {
427 label: None,
428 placeholder: None,
429 state,
430 style: InputStyle::default(),
431 focus_id: FocusId::default(),
432 with_border: true,
433 }
434 }
435
436 pub fn label(mut self, label: &'a str) -> Self {
438 self.label = Some(label);
439 self
440 }
441
442 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
444 self.placeholder = Some(placeholder);
445 self
446 }
447
448 pub fn style(mut self, style: InputStyle) -> Self {
450 self.style = style;
451 self
452 }
453
454 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
456 self.style(InputStyle::from(theme))
457 }
458
459 pub fn focus_id(mut self, id: FocusId) -> Self {
461 self.focus_id = id;
462 self
463 }
464
465 pub fn with_border(mut self, with_border: bool) -> Self {
467 self.with_border = with_border;
468 self
469 }
470
471 pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<InputAction> {
473 let border_color = if !self.state.enabled {
474 self.style.disabled_border
475 } else if self.state.focused {
476 self.style.focused_border
477 } else {
478 self.style.unfocused_border
479 };
480
481 let block = if self.with_border {
482 let mut block = Block::default()
483 .borders(Borders::ALL)
484 .border_style(Style::default().fg(border_color));
485 if let Some(label) = self.label {
486 block = block.title(format!(" {} ", label));
487 }
488 Some(block)
489 } else {
490 None
491 };
492
493 let inner_area = if let Some(ref b) = block {
494 b.inner(area)
495 } else {
496 area
497 };
498
499 let display_line = if self.state.text.is_empty() {
501 if let Some(placeholder) = self.placeholder {
502 Line::from(Span::styled(
503 placeholder,
504 Style::default().fg(self.style.placeholder_fg),
505 ))
506 } else if self.state.focused {
507 Line::from(Span::styled("│", Style::default().fg(self.style.cursor_fg)))
509 } else {
510 Line::from("")
511 }
512 } else {
513 let before = self.state.text_before_cursor();
514 let after = self.state.text_after_cursor();
515
516 let mut spans = vec![Span::styled(
517 before.to_string(),
518 Style::default().fg(self.style.text_fg),
519 )];
520
521 if self.state.focused {
522 spans.push(Span::styled("│", Style::default().fg(self.style.cursor_fg)));
523 }
524
525 spans.push(Span::styled(
526 after.to_string(),
527 Style::default().fg(self.style.text_fg),
528 ));
529
530 Line::from(spans)
531 };
532
533 let paragraph = Paragraph::new(display_line);
534
535 if let Some(block) = block {
536 frame.render_widget(block, area);
537 }
538 frame.render_widget(paragraph, inner_area);
539
540 ClickRegion::new(area, InputAction::Focus)
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn test_state_default() {
550 let state = InputState::default();
551 assert!(state.text.is_empty());
552 assert_eq!(state.cursor_pos, 0);
553 assert!(!state.focused);
554 assert!(state.enabled);
555 }
556
557 #[test]
558 fn test_state_new() {
559 let state = InputState::new("Hello");
560 assert_eq!(state.text, "Hello");
561 assert_eq!(state.cursor_pos, 5); }
563
564 #[test]
565 fn test_insert_char() {
566 let mut state = InputState::new("Hello");
567 state.insert_char('!');
568 assert_eq!(state.text, "Hello!");
569 assert_eq!(state.cursor_pos, 6);
570 }
571
572 #[test]
573 fn test_insert_char_middle() {
574 let mut state = InputState::new("Hllo");
575 state.cursor_pos = 1;
576 state.insert_char('e');
577 assert_eq!(state.text, "Hello");
578 assert_eq!(state.cursor_pos, 2);
579 }
580
581 #[test]
582 fn test_insert_str() {
583 let mut state = InputState::new("Hello");
584 state.insert_str(" World");
585 assert_eq!(state.text, "Hello World");
586 }
587
588 #[test]
589 fn test_delete_char_backward() {
590 let mut state = InputState::new("Hello");
591 assert!(state.delete_char_backward());
592 assert_eq!(state.text, "Hell");
593 assert_eq!(state.cursor_pos, 4);
594 }
595
596 #[test]
597 fn test_delete_char_backward_at_start() {
598 let mut state = InputState::new("Hello");
599 state.cursor_pos = 0;
600 assert!(!state.delete_char_backward());
601 assert_eq!(state.text, "Hello");
602 }
603
604 #[test]
605 fn test_delete_char_forward() {
606 let mut state = InputState::new("Hello");
607 state.cursor_pos = 0;
608 assert!(state.delete_char_forward());
609 assert_eq!(state.text, "ello");
610 }
611
612 #[test]
613 fn test_delete_char_forward_at_end() {
614 let mut state = InputState::new("Hello");
615 assert!(!state.delete_char_forward());
616 assert_eq!(state.text, "Hello");
617 }
618
619 #[test]
620 fn test_move_cursor() {
621 let mut state = InputState::new("Hello");
622 assert_eq!(state.cursor_pos, 5);
623
624 state.move_left();
625 assert_eq!(state.cursor_pos, 4);
626
627 state.move_right();
628 assert_eq!(state.cursor_pos, 5);
629
630 state.move_home();
631 assert_eq!(state.cursor_pos, 0);
632
633 state.move_end();
634 assert_eq!(state.cursor_pos, 5);
635 }
636
637 #[test]
638 fn test_move_cursor_bounds() {
639 let mut state = InputState::new("Hi");
640
641 state.move_home();
642 state.move_left(); assert_eq!(state.cursor_pos, 0);
644
645 state.move_end();
646 state.move_right(); assert_eq!(state.cursor_pos, 2);
648 }
649
650 #[test]
651 fn test_move_word() {
652 let mut state = InputState::new("Hello World Test");
653
654 state.move_home();
655 state.move_word_right();
656 assert_eq!(state.cursor_pos, 6); state.move_word_right();
659 assert_eq!(state.cursor_pos, 12); state.move_word_left();
662 assert_eq!(state.cursor_pos, 6); }
664
665 #[test]
666 fn test_clear() {
667 let mut state = InputState::new("Hello");
668 state.clear();
669 assert!(state.text.is_empty());
670 assert_eq!(state.cursor_pos, 0);
671 }
672
673 #[test]
674 fn test_set_text() {
675 let mut state = InputState::new("Hello");
676 state.set_text("World");
677 assert_eq!(state.text, "World");
678 assert_eq!(state.cursor_pos, 5);
679 }
680
681 #[test]
682 fn test_text_before_after_cursor() {
683 let mut state = InputState::new("Hello");
684 state.cursor_pos = 2;
685
686 assert_eq!(state.text_before_cursor(), "He");
687 assert_eq!(state.text_after_cursor(), "llo");
688 }
689
690 #[test]
691 fn test_unicode_handling() {
692 let mut state = InputState::new("你好");
693 assert_eq!(state.cursor_pos, 2); state.move_left();
696 assert_eq!(state.cursor_pos, 1);
697
698 state.insert_char('世');
699 assert_eq!(state.text, "你世好");
700 }
701
702 #[test]
703 fn test_emoji_handling() {
704 let mut state = InputState::new("Hi 👋");
705 assert_eq!(state.len(), 4); state.delete_char_backward();
708 assert_eq!(state.text, "Hi ");
709 }
710
711 #[test]
712 fn test_disabled_input() {
713 let mut state = InputState::new("Hello");
714 state.enabled = false;
715
716 state.insert_char('!');
717 assert_eq!(state.text, "Hello"); assert!(!state.delete_char_backward());
720 assert_eq!(state.text, "Hello"); }
722
723 #[test]
724 fn test_is_empty_and_len() {
725 let state = InputState::empty();
726 assert!(state.is_empty());
727 assert_eq!(state.len(), 0);
728
729 let state = InputState::new("Test");
730 assert!(!state.is_empty());
731 assert_eq!(state.len(), 4);
732 }
733
734 #[test]
735 fn test_input_style_builder() {
736 let style = InputStyle::default()
737 .focused_border(Color::Cyan)
738 .text_fg(Color::Green);
739
740 assert_eq!(style.focused_border, Color::Cyan);
741 assert_eq!(style.text_fg, Color::Green);
742 }
743}