1use super::*;
2
3impl Context {
4 pub fn text_input(&mut self, state: &mut TextInputState) -> Response {
20 self.text_input_colored(state, &WidgetColors::new())
21 }
22
23 pub fn text_input_colored(
25 &mut self,
26 state: &mut TextInputState,
27 colors: &WidgetColors,
28 ) -> Response {
29 slt_assert(
30 !state.value.contains('\n'),
31 "text_input got a newline — use textarea instead",
32 );
33 let focused = self.register_focusable();
34 let old_value = state.value.clone();
35 state.cursor = state.cursor.min(state.value.chars().count());
36
37 if focused {
38 let mut consumed_indices = Vec::new();
39 for (i, event) in self.events.iter().enumerate() {
40 if let Event::Key(key) = event {
41 if key.kind != KeyEventKind::Press {
42 continue;
43 }
44 let matched_suggestions = if state.show_suggestions {
45 state
46 .matched_suggestions()
47 .into_iter()
48 .map(str::to_string)
49 .collect::<Vec<String>>()
50 } else {
51 Vec::new()
52 };
53 let suggestions_visible = !matched_suggestions.is_empty();
54 if suggestions_visible {
55 state.suggestion_index = state
56 .suggestion_index
57 .min(matched_suggestions.len().saturating_sub(1));
58 }
59 match key.code {
60 KeyCode::Up if suggestions_visible => {
61 state.suggestion_index = state.suggestion_index.saturating_sub(1);
62 consumed_indices.push(i);
63 }
64 KeyCode::Down if suggestions_visible => {
65 state.suggestion_index = (state.suggestion_index + 1)
66 .min(matched_suggestions.len().saturating_sub(1));
67 consumed_indices.push(i);
68 }
69 KeyCode::Esc if state.show_suggestions => {
70 state.show_suggestions = false;
71 state.suggestion_index = 0;
72 consumed_indices.push(i);
73 }
74 KeyCode::Tab if suggestions_visible => {
75 if let Some(selected) = matched_suggestions
76 .get(state.suggestion_index)
77 .or_else(|| matched_suggestions.first())
78 {
79 state.value = selected.clone();
80 state.cursor = state.value.chars().count();
81 state.show_suggestions = false;
82 state.suggestion_index = 0;
83 }
84 consumed_indices.push(i);
85 }
86 KeyCode::Char(ch) => {
87 if let Some(max) = state.max_length {
88 if state.value.chars().count() >= max {
89 continue;
90 }
91 }
92 let index = byte_index_for_char(&state.value, state.cursor);
93 state.value.insert(index, ch);
94 state.cursor += 1;
95 if !state.suggestions.is_empty() {
96 state.show_suggestions = true;
97 state.suggestion_index = 0;
98 }
99 consumed_indices.push(i);
100 }
101 KeyCode::Backspace => {
102 if state.cursor > 0 {
103 let start = byte_index_for_char(&state.value, state.cursor - 1);
104 let end = byte_index_for_char(&state.value, state.cursor);
105 state.value.replace_range(start..end, "");
106 state.cursor -= 1;
107 }
108 if !state.suggestions.is_empty() {
109 state.show_suggestions = true;
110 state.suggestion_index = 0;
111 }
112 consumed_indices.push(i);
113 }
114 KeyCode::Left => {
115 state.cursor = state.cursor.saturating_sub(1);
116 consumed_indices.push(i);
117 }
118 KeyCode::Right => {
119 state.cursor = (state.cursor + 1).min(state.value.chars().count());
120 consumed_indices.push(i);
121 }
122 KeyCode::Home => {
123 state.cursor = 0;
124 consumed_indices.push(i);
125 }
126 KeyCode::Delete => {
127 let len = state.value.chars().count();
128 if state.cursor < len {
129 let start = byte_index_for_char(&state.value, state.cursor);
130 let end = byte_index_for_char(&state.value, state.cursor + 1);
131 state.value.replace_range(start..end, "");
132 }
133 if !state.suggestions.is_empty() {
134 state.show_suggestions = true;
135 state.suggestion_index = 0;
136 }
137 consumed_indices.push(i);
138 }
139 KeyCode::End => {
140 state.cursor = state.value.chars().count();
141 consumed_indices.push(i);
142 }
143 _ => {}
144 }
145 }
146 if let Event::Paste(ref text) = event {
147 for ch in text.chars() {
148 if let Some(max) = state.max_length {
149 if state.value.chars().count() >= max {
150 break;
151 }
152 }
153 let index = byte_index_for_char(&state.value, state.cursor);
154 state.value.insert(index, ch);
155 state.cursor += 1;
156 }
157 if !state.suggestions.is_empty() {
158 state.show_suggestions = true;
159 state.suggestion_index = 0;
160 }
161 consumed_indices.push(i);
162 }
163 }
164
165 for index in consumed_indices {
166 self.consumed[index] = true;
167 }
168 }
169
170 if state.value.is_empty() {
171 state.show_suggestions = false;
172 state.suggestion_index = 0;
173 }
174
175 let matched_suggestions = if state.show_suggestions {
176 state
177 .matched_suggestions()
178 .into_iter()
179 .map(str::to_string)
180 .collect::<Vec<String>>()
181 } else {
182 Vec::new()
183 };
184 if !matched_suggestions.is_empty() {
185 state.suggestion_index = state
186 .suggestion_index
187 .min(matched_suggestions.len().saturating_sub(1));
188 }
189
190 let visible_width = self.area_width.saturating_sub(4) as usize;
191 let (input_text, cursor_offset) = if state.value.is_empty() {
192 if state.placeholder.len() > 100 {
193 slt_warn(
194 "text_input placeholder is very long (>100 chars) — consider shortening it",
195 );
196 }
197 let mut ph = state.placeholder.clone();
198 if focused {
199 ph.insert(0, '▎');
200 (ph, Some(0))
201 } else {
202 (ph, None)
203 }
204 } else {
205 let chars: Vec<char> = state.value.chars().collect();
206 let display_chars: Vec<char> = if state.masked {
207 vec!['•'; chars.len()]
208 } else {
209 chars.clone()
210 };
211
212 let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
213 .iter()
214 .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
215 .sum();
216
217 let scroll_offset = if cursor_display_pos >= visible_width {
218 cursor_display_pos - visible_width + 1
219 } else {
220 0
221 };
222
223 let mut rendered = String::new();
224 let mut cursor_offset = None;
225 let mut current_width: usize = 0;
226 for (idx, &ch) in display_chars.iter().enumerate() {
227 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
228 if current_width + cw <= scroll_offset {
229 current_width += cw;
230 continue;
231 }
232 if current_width - scroll_offset >= visible_width {
233 break;
234 }
235 if focused && idx == state.cursor {
236 cursor_offset = Some(rendered.chars().count());
237 rendered.push('▎');
238 }
239 rendered.push(ch);
240 current_width += cw;
241 }
242 if focused && state.cursor >= display_chars.len() {
243 cursor_offset = Some(rendered.chars().count());
244 rendered.push('▎');
245 }
246 (rendered, cursor_offset)
247 };
248 let input_style = if state.value.is_empty() && !focused {
249 Style::new()
250 .dim()
251 .fg(colors.fg.unwrap_or(self.theme.text_dim))
252 } else {
253 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
254 };
255
256 let border_color = if focused {
257 colors.accent.unwrap_or(self.theme.primary)
258 } else if state.validation_error.is_some() {
259 colors.accent.unwrap_or(self.theme.error)
260 } else {
261 colors.border.unwrap_or(self.theme.border)
262 };
263
264 let mut response = self
265 .bordered(Border::Rounded)
266 .border_style(Style::new().fg(border_color))
267 .px(1)
268 .grow(1)
269 .col(|ui| {
270 ui.styled_with_cursor(input_text, input_style, cursor_offset);
271 });
272 response.focused = focused;
273 response.changed = state.value != old_value;
274
275 let errors = state.errors();
276 if !errors.is_empty() {
277 for error in errors {
278 let mut warning = String::with_capacity(2 + error.len());
279 warning.push_str("⚠ ");
280 warning.push_str(error);
281 self.styled(
282 warning,
283 Style::new()
284 .dim()
285 .fg(colors.accent.unwrap_or(self.theme.error)),
286 );
287 }
288 } else if let Some(error) = state.validation_error.clone() {
289 let mut warning = String::with_capacity(2 + error.len());
290 warning.push_str("⚠ ");
291 warning.push_str(&error);
292 self.styled(
293 warning,
294 Style::new()
295 .dim()
296 .fg(colors.accent.unwrap_or(self.theme.error)),
297 );
298 }
299
300 if state.show_suggestions && !matched_suggestions.is_empty() {
301 let start = state.suggestion_index.saturating_sub(4);
302 let end = (start + 5).min(matched_suggestions.len());
303 let suggestion_border = colors.border.unwrap_or(self.theme.border);
304 let _ = self
305 .bordered(Border::Rounded)
306 .border_style(Style::new().fg(suggestion_border))
307 .px(1)
308 .col(|ui| {
309 for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
310 let actual_idx = start + idx;
311 if actual_idx == state.suggestion_index {
312 ui.styled(
313 suggestion.clone(),
314 Style::new()
315 .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
316 .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
317 );
318 } else {
319 ui.styled(
320 suggestion.clone(),
321 Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
322 );
323 }
324 }
325 });
326 }
327 response
328 }
329
330 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
336 self.styled(
337 state.frame(self.tick).to_string(),
338 Style::new().fg(self.theme.primary),
339 )
340 }
341
342 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
347 state.cleanup(self.tick);
348 if state.messages.is_empty() {
349 return self;
350 }
351
352 self.interaction_count += 1;
353 self.commands.push(Command::BeginContainer {
354 direction: Direction::Column,
355 gap: 0,
356 align: Align::Start,
357 align_self: None,
358 justify: Justify::Start,
359 border: None,
360 border_sides: BorderSides::all(),
361 border_style: Style::new().fg(self.theme.border),
362 bg_color: None,
363 padding: Padding::default(),
364 margin: Margin::default(),
365 constraints: Constraints::default(),
366 title: None,
367 grow: 0,
368 group_name: None,
369 });
370 for message in state.messages.iter().rev() {
371 let color = match message.level {
372 ToastLevel::Info => self.theme.primary,
373 ToastLevel::Success => self.theme.success,
374 ToastLevel::Warning => self.theme.warning,
375 ToastLevel::Error => self.theme.error,
376 };
377 let mut line = String::with_capacity(4 + message.text.len());
378 line.push_str(" ● ");
379 line.push_str(&message.text);
380 self.styled(line, Style::new().fg(color));
381 }
382 self.commands.push(Command::EndContainer);
383 self.last_text_idx = None;
384
385 self
386 }
387
388 pub fn slider(
400 &mut self,
401 label: &str,
402 value: &mut f64,
403 range: std::ops::RangeInclusive<f64>,
404 ) -> Response {
405 let focused = self.register_focusable();
406 let mut changed = false;
407
408 let start = *range.start();
409 let end = *range.end();
410 let span = (end - start).max(0.0);
411 let step = if span > 0.0 { span / 20.0 } else { 0.0 };
412
413 *value = (*value).clamp(start, end);
414
415 if focused {
416 let mut consumed_indices = Vec::new();
417 for (i, event) in self.events.iter().enumerate() {
418 if let Event::Key(key) = event {
419 if key.kind != KeyEventKind::Press {
420 continue;
421 }
422
423 match key.code {
424 KeyCode::Left | KeyCode::Char('h') => {
425 if step > 0.0 {
426 let next = (*value - step).max(start);
427 if (next - *value).abs() > f64::EPSILON {
428 *value = next;
429 changed = true;
430 }
431 }
432 consumed_indices.push(i);
433 }
434 KeyCode::Right | KeyCode::Char('l') => {
435 if step > 0.0 {
436 let next = (*value + step).min(end);
437 if (next - *value).abs() > f64::EPSILON {
438 *value = next;
439 changed = true;
440 }
441 }
442 consumed_indices.push(i);
443 }
444 _ => {}
445 }
446 }
447 }
448
449 for idx in consumed_indices {
450 self.consumed[idx] = true;
451 }
452 }
453
454 let ratio = if span <= f64::EPSILON {
455 0.0
456 } else {
457 ((*value - start) / span).clamp(0.0, 1.0)
458 };
459
460 let value_text = format_compact_number(*value);
461 let label_width = UnicodeWidthStr::width(label) as u32;
462 let value_width = UnicodeWidthStr::width(value_text.as_str()) as u32;
463 let track_width = self
464 .area_width
465 .saturating_sub(label_width + value_width + 8)
466 .max(10) as usize;
467 let thumb_idx = if track_width <= 1 {
468 0
469 } else {
470 (ratio * (track_width as f64 - 1.0)).round() as usize
471 };
472
473 let mut track = String::with_capacity(track_width);
474 for i in 0..track_width {
475 if i == thumb_idx {
476 track.push('○');
477 } else if i < thumb_idx {
478 track.push('█');
479 } else {
480 track.push('━');
481 }
482 }
483
484 let text_color = self.theme.text;
485 let border_color = self.theme.border;
486 let primary_color = self.theme.primary;
487 let dim_color = self.theme.text_dim;
488 let mut response = self.container().row(|ui| {
489 ui.text(label).fg(text_color);
490 ui.text("[").fg(border_color);
491 ui.text(track).grow(1).fg(primary_color);
492 ui.text("]").fg(border_color);
493 if focused {
494 ui.text(value_text.as_str()).bold().fg(primary_color);
495 } else {
496 ui.text(value_text.as_str()).fg(dim_color);
497 }
498 });
499 response.focused = focused;
500 response.changed = changed;
501 response
502 }
503
504 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> Response {
512 if state.lines.is_empty() {
513 state.lines.push(String::new());
514 }
515 let old_lines = state.lines.clone();
516 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
517 state.cursor_col = state
518 .cursor_col
519 .min(state.lines[state.cursor_row].chars().count());
520
521 let focused = self.register_focusable();
522 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
523 let wrapping = state.wrap_width.is_some();
524
525 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
526
527 if focused {
528 let mut consumed_indices = Vec::new();
529 for (i, event) in self.events.iter().enumerate() {
530 if let Event::Key(key) = event {
531 if key.kind != KeyEventKind::Press {
532 continue;
533 }
534 match key.code {
535 KeyCode::Char(ch) => {
536 if let Some(max) = state.max_length {
537 let total: usize =
538 state.lines.iter().map(|line| line.chars().count()).sum();
539 if total >= max {
540 continue;
541 }
542 }
543 let index = byte_index_for_char(
544 &state.lines[state.cursor_row],
545 state.cursor_col,
546 );
547 state.lines[state.cursor_row].insert(index, ch);
548 state.cursor_col += 1;
549 consumed_indices.push(i);
550 }
551 KeyCode::Enter => {
552 let split_index = byte_index_for_char(
553 &state.lines[state.cursor_row],
554 state.cursor_col,
555 );
556 let remainder = state.lines[state.cursor_row].split_off(split_index);
557 state.cursor_row += 1;
558 state.lines.insert(state.cursor_row, remainder);
559 state.cursor_col = 0;
560 consumed_indices.push(i);
561 }
562 KeyCode::Backspace => {
563 if state.cursor_col > 0 {
564 let start = byte_index_for_char(
565 &state.lines[state.cursor_row],
566 state.cursor_col - 1,
567 );
568 let end = byte_index_for_char(
569 &state.lines[state.cursor_row],
570 state.cursor_col,
571 );
572 state.lines[state.cursor_row].replace_range(start..end, "");
573 state.cursor_col -= 1;
574 } else if state.cursor_row > 0 {
575 let current = state.lines.remove(state.cursor_row);
576 state.cursor_row -= 1;
577 state.cursor_col = state.lines[state.cursor_row].chars().count();
578 state.lines[state.cursor_row].push_str(¤t);
579 }
580 consumed_indices.push(i);
581 }
582 KeyCode::Left => {
583 if state.cursor_col > 0 {
584 state.cursor_col -= 1;
585 } else if state.cursor_row > 0 {
586 state.cursor_row -= 1;
587 state.cursor_col = state.lines[state.cursor_row].chars().count();
588 }
589 consumed_indices.push(i);
590 }
591 KeyCode::Right => {
592 let line_len = state.lines[state.cursor_row].chars().count();
593 if state.cursor_col < line_len {
594 state.cursor_col += 1;
595 } else if state.cursor_row + 1 < state.lines.len() {
596 state.cursor_row += 1;
597 state.cursor_col = 0;
598 }
599 consumed_indices.push(i);
600 }
601 KeyCode::Up => {
602 if wrapping {
603 let (vrow, vcol) = textarea_logical_to_visual(
604 &pre_vlines,
605 state.cursor_row,
606 state.cursor_col,
607 );
608 if vrow > 0 {
609 let (lr, lc) =
610 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
611 state.cursor_row = lr;
612 state.cursor_col = lc;
613 }
614 } else if state.cursor_row > 0 {
615 state.cursor_row -= 1;
616 state.cursor_col = state
617 .cursor_col
618 .min(state.lines[state.cursor_row].chars().count());
619 }
620 consumed_indices.push(i);
621 }
622 KeyCode::Down => {
623 if wrapping {
624 let (vrow, vcol) = textarea_logical_to_visual(
625 &pre_vlines,
626 state.cursor_row,
627 state.cursor_col,
628 );
629 if vrow + 1 < pre_vlines.len() {
630 let (lr, lc) =
631 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
632 state.cursor_row = lr;
633 state.cursor_col = lc;
634 }
635 } else if state.cursor_row + 1 < state.lines.len() {
636 state.cursor_row += 1;
637 state.cursor_col = state
638 .cursor_col
639 .min(state.lines[state.cursor_row].chars().count());
640 }
641 consumed_indices.push(i);
642 }
643 KeyCode::Home => {
644 state.cursor_col = 0;
645 consumed_indices.push(i);
646 }
647 KeyCode::Delete => {
648 let line_len = state.lines[state.cursor_row].chars().count();
649 if state.cursor_col < line_len {
650 let start = byte_index_for_char(
651 &state.lines[state.cursor_row],
652 state.cursor_col,
653 );
654 let end = byte_index_for_char(
655 &state.lines[state.cursor_row],
656 state.cursor_col + 1,
657 );
658 state.lines[state.cursor_row].replace_range(start..end, "");
659 } else if state.cursor_row + 1 < state.lines.len() {
660 let next = state.lines.remove(state.cursor_row + 1);
661 state.lines[state.cursor_row].push_str(&next);
662 }
663 consumed_indices.push(i);
664 }
665 KeyCode::End => {
666 state.cursor_col = state.lines[state.cursor_row].chars().count();
667 consumed_indices.push(i);
668 }
669 _ => {}
670 }
671 }
672 if let Event::Paste(ref text) = event {
673 for ch in text.chars() {
674 if ch == '\n' || ch == '\r' {
675 let split_index = byte_index_for_char(
676 &state.lines[state.cursor_row],
677 state.cursor_col,
678 );
679 let remainder = state.lines[state.cursor_row].split_off(split_index);
680 state.cursor_row += 1;
681 state.lines.insert(state.cursor_row, remainder);
682 state.cursor_col = 0;
683 } else {
684 if let Some(max) = state.max_length {
685 let total: usize =
686 state.lines.iter().map(|l| l.chars().count()).sum();
687 if total >= max {
688 break;
689 }
690 }
691 let index = byte_index_for_char(
692 &state.lines[state.cursor_row],
693 state.cursor_col,
694 );
695 state.lines[state.cursor_row].insert(index, ch);
696 state.cursor_col += 1;
697 }
698 }
699 consumed_indices.push(i);
700 }
701 }
702
703 for index in consumed_indices {
704 self.consumed[index] = true;
705 }
706 }
707
708 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
709 let (cursor_vrow, cursor_vcol) =
710 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
711
712 if cursor_vrow < state.scroll_offset {
713 state.scroll_offset = cursor_vrow;
714 }
715 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
716 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
717 }
718
719 let interaction_id = self.next_interaction_id();
720 let mut response = self.response_for(interaction_id);
721 response.focused = focused;
722 self.commands.push(Command::BeginContainer {
723 direction: Direction::Column,
724 gap: 0,
725 align: Align::Start,
726 align_self: None,
727 justify: Justify::Start,
728 border: None,
729 border_sides: BorderSides::all(),
730 border_style: Style::new().fg(self.theme.border),
731 bg_color: None,
732 padding: Padding::default(),
733 margin: Margin::default(),
734 constraints: Constraints::default(),
735 title: None,
736 grow: 0,
737 group_name: None,
738 });
739
740 for vi in 0..visible_rows as usize {
741 let actual_vi = state.scroll_offset + vi;
742 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
743 let line = &state.lines[vl.logical_row];
744 let text: String = line
745 .chars()
746 .skip(vl.char_start)
747 .take(vl.char_count)
748 .collect();
749 (text, actual_vi == cursor_vrow)
750 } else {
751 (String::new(), false)
752 };
753
754 let mut rendered = seg_text.clone();
755 let mut cursor_offset = None;
756 let mut style = if seg_text.is_empty() {
757 Style::new().fg(self.theme.text_dim)
758 } else {
759 Style::new().fg(self.theme.text)
760 };
761
762 if is_cursor_line && focused {
763 rendered.clear();
764 for (idx, ch) in seg_text.chars().enumerate() {
765 if idx == cursor_vcol {
766 cursor_offset = Some(rendered.chars().count());
767 rendered.push('▎');
768 }
769 rendered.push(ch);
770 }
771 if cursor_vcol >= seg_text.chars().count() {
772 cursor_offset = Some(rendered.chars().count());
773 rendered.push('▎');
774 }
775 style = Style::new().fg(self.theme.text);
776 }
777
778 self.styled_with_cursor(rendered, style, cursor_offset);
779 }
780 self.commands.push(Command::EndContainer);
781 self.last_text_idx = None;
782
783 response.changed = state.lines != old_lines;
784 response
785 }
786
787 pub fn progress(&mut self, ratio: f64) -> &mut Self {
792 self.progress_bar(ratio, 20)
793 }
794
795 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
801 self.progress_bar_colored(ratio, width, self.theme.primary)
802 }
803
804 pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> &mut Self {
806 let clamped = ratio.clamp(0.0, 1.0);
807 let filled = (clamped * width as f64).round() as u32;
808 let empty = width.saturating_sub(filled);
809 let mut bar = String::new();
810 for _ in 0..filled {
811 bar.push('█');
812 }
813 for _ in 0..empty {
814 bar.push('░');
815 }
816 self.styled(bar, Style::new().fg(color))
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use crate::{EventBuilder, KeyCode, TestBackend};
824
825 #[test]
826 fn text_input_shows_matched_suggestions_for_prefix() {
827 let mut backend = TestBackend::new(40, 10);
828 let mut input = TextInputState::new();
829 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
830
831 let events = EventBuilder::new().key('h').key('e').key('l').build();
832 backend.run_with_events(events, |ui| {
833 let _ = ui.text_input(&mut input);
834 });
835
836 backend.assert_contains("hello");
837 backend.assert_contains("help");
838 assert!(!backend.to_string_trimmed().contains("world"));
839 assert_eq!(input.matched_suggestions().len(), 2);
840 }
841
842 #[test]
843 fn text_input_tab_accepts_top_suggestion() {
844 let mut backend = TestBackend::new(40, 10);
845 let mut input = TextInputState::new();
846 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
847
848 let events = EventBuilder::new()
849 .key('h')
850 .key('e')
851 .key('l')
852 .key_code(KeyCode::Tab)
853 .build();
854 backend.run_with_events(events, |ui| {
855 let _ = ui.text_input(&mut input);
856 });
857
858 assert_eq!(input.value, "hello");
859 assert!(!input.show_suggestions);
860 }
861
862 #[test]
863 fn text_input_empty_value_shows_no_suggestions() {
864 let mut backend = TestBackend::new(40, 10);
865 let mut input = TextInputState::new();
866 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
867
868 backend.render(|ui| {
869 let _ = ui.text_input(&mut input);
870 });
871
872 let rendered = backend.to_string_trimmed();
873 assert!(!rendered.contains("hello"));
874 assert!(!rendered.contains("help"));
875 assert!(!rendered.contains("world"));
876 assert!(input.matched_suggestions().is_empty());
877 assert!(!input.show_suggestions);
878 }
879}