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 = 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 }
201 ph
202 } else {
203 let chars: Vec<char> = state.value.chars().collect();
204 let display_chars: Vec<char> = if state.masked {
205 vec!['•'; chars.len()]
206 } else {
207 chars.clone()
208 };
209
210 let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
211 .iter()
212 .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
213 .sum();
214
215 let scroll_offset = if cursor_display_pos >= visible_width {
216 cursor_display_pos - visible_width + 1
217 } else {
218 0
219 };
220
221 let mut rendered = String::new();
222 let mut current_width: usize = 0;
223 for (idx, &ch) in display_chars.iter().enumerate() {
224 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
225 if current_width + cw <= scroll_offset {
226 current_width += cw;
227 continue;
228 }
229 if current_width - scroll_offset >= visible_width {
230 break;
231 }
232 if focused && idx == state.cursor {
233 rendered.push('▎');
234 }
235 rendered.push(ch);
236 current_width += cw;
237 }
238 if focused && state.cursor >= display_chars.len() {
239 rendered.push('▎');
240 }
241 rendered
242 };
243 let input_style = if state.value.is_empty() && !focused {
244 Style::new()
245 .dim()
246 .fg(colors.fg.unwrap_or(self.theme.text_dim))
247 } else {
248 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
249 };
250
251 let border_color = if focused {
252 colors.accent.unwrap_or(self.theme.primary)
253 } else if state.validation_error.is_some() {
254 colors.accent.unwrap_or(self.theme.error)
255 } else {
256 colors.border.unwrap_or(self.theme.border)
257 };
258
259 let mut response = self
260 .bordered(Border::Rounded)
261 .border_style(Style::new().fg(border_color))
262 .px(1)
263 .grow(1)
264 .col(|ui| {
265 ui.styled(input_text, input_style);
266 });
267 response.focused = focused;
268 response.changed = state.value != old_value;
269
270 let errors = state.errors();
271 if !errors.is_empty() {
272 for error in errors {
273 let mut warning = String::with_capacity(2 + error.len());
274 warning.push_str("⚠ ");
275 warning.push_str(error);
276 self.styled(
277 warning,
278 Style::new()
279 .dim()
280 .fg(colors.accent.unwrap_or(self.theme.error)),
281 );
282 }
283 } else if let Some(error) = state.validation_error.clone() {
284 let mut warning = String::with_capacity(2 + error.len());
285 warning.push_str("⚠ ");
286 warning.push_str(&error);
287 self.styled(
288 warning,
289 Style::new()
290 .dim()
291 .fg(colors.accent.unwrap_or(self.theme.error)),
292 );
293 }
294
295 if state.show_suggestions && !matched_suggestions.is_empty() {
296 let start = state.suggestion_index.saturating_sub(4);
297 let end = (start + 5).min(matched_suggestions.len());
298 let suggestion_border = colors.border.unwrap_or(self.theme.border);
299 let _ = self
300 .bordered(Border::Rounded)
301 .border_style(Style::new().fg(suggestion_border))
302 .px(1)
303 .col(|ui| {
304 for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
305 let actual_idx = start + idx;
306 if actual_idx == state.suggestion_index {
307 ui.styled(
308 suggestion.clone(),
309 Style::new()
310 .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
311 .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
312 );
313 } else {
314 ui.styled(
315 suggestion.clone(),
316 Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
317 );
318 }
319 }
320 });
321 }
322 response
323 }
324
325 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
331 self.styled(
332 state.frame(self.tick).to_string(),
333 Style::new().fg(self.theme.primary),
334 )
335 }
336
337 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
342 state.cleanup(self.tick);
343 if state.messages.is_empty() {
344 return self;
345 }
346
347 self.interaction_count += 1;
348 self.commands.push(Command::BeginContainer {
349 direction: Direction::Column,
350 gap: 0,
351 align: Align::Start,
352 align_self: None,
353 justify: Justify::Start,
354 border: None,
355 border_sides: BorderSides::all(),
356 border_style: Style::new().fg(self.theme.border),
357 bg_color: None,
358 padding: Padding::default(),
359 margin: Margin::default(),
360 constraints: Constraints::default(),
361 title: None,
362 grow: 0,
363 group_name: None,
364 });
365 for message in state.messages.iter().rev() {
366 let color = match message.level {
367 ToastLevel::Info => self.theme.primary,
368 ToastLevel::Success => self.theme.success,
369 ToastLevel::Warning => self.theme.warning,
370 ToastLevel::Error => self.theme.error,
371 };
372 let mut line = String::with_capacity(4 + message.text.len());
373 line.push_str(" ● ");
374 line.push_str(&message.text);
375 self.styled(line, Style::new().fg(color));
376 }
377 self.commands.push(Command::EndContainer);
378 self.last_text_idx = None;
379
380 self
381 }
382
383 pub fn slider(
395 &mut self,
396 label: &str,
397 value: &mut f64,
398 range: std::ops::RangeInclusive<f64>,
399 ) -> Response {
400 let focused = self.register_focusable();
401 let mut changed = false;
402
403 let start = *range.start();
404 let end = *range.end();
405 let span = (end - start).max(0.0);
406 let step = if span > 0.0 { span / 20.0 } else { 0.0 };
407
408 *value = (*value).clamp(start, end);
409
410 if focused {
411 let mut consumed_indices = Vec::new();
412 for (i, event) in self.events.iter().enumerate() {
413 if let Event::Key(key) = event {
414 if key.kind != KeyEventKind::Press {
415 continue;
416 }
417
418 match key.code {
419 KeyCode::Left | KeyCode::Char('h') => {
420 if step > 0.0 {
421 let next = (*value - step).max(start);
422 if (next - *value).abs() > f64::EPSILON {
423 *value = next;
424 changed = true;
425 }
426 }
427 consumed_indices.push(i);
428 }
429 KeyCode::Right | KeyCode::Char('l') => {
430 if step > 0.0 {
431 let next = (*value + step).min(end);
432 if (next - *value).abs() > f64::EPSILON {
433 *value = next;
434 changed = true;
435 }
436 }
437 consumed_indices.push(i);
438 }
439 _ => {}
440 }
441 }
442 }
443
444 for idx in consumed_indices {
445 self.consumed[idx] = true;
446 }
447 }
448
449 let ratio = if span <= f64::EPSILON {
450 0.0
451 } else {
452 ((*value - start) / span).clamp(0.0, 1.0)
453 };
454
455 let value_text = format_compact_number(*value);
456 let label_width = UnicodeWidthStr::width(label) as u32;
457 let value_width = UnicodeWidthStr::width(value_text.as_str()) as u32;
458 let track_width = self
459 .area_width
460 .saturating_sub(label_width + value_width + 8)
461 .max(10) as usize;
462 let thumb_idx = if track_width <= 1 {
463 0
464 } else {
465 (ratio * (track_width as f64 - 1.0)).round() as usize
466 };
467
468 let mut track = String::with_capacity(track_width);
469 for i in 0..track_width {
470 if i == thumb_idx {
471 track.push('○');
472 } else if i < thumb_idx {
473 track.push('█');
474 } else {
475 track.push('━');
476 }
477 }
478
479 let text_color = self.theme.text;
480 let border_color = self.theme.border;
481 let primary_color = self.theme.primary;
482 let dim_color = self.theme.text_dim;
483 let mut response = self.container().row(|ui| {
484 ui.text(label).fg(text_color);
485 ui.text("[").fg(border_color);
486 ui.text(track).grow(1).fg(primary_color);
487 ui.text("]").fg(border_color);
488 if focused {
489 ui.text(value_text.as_str()).bold().fg(primary_color);
490 } else {
491 ui.text(value_text.as_str()).fg(dim_color);
492 }
493 });
494 response.focused = focused;
495 response.changed = changed;
496 response
497 }
498
499 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> Response {
507 if state.lines.is_empty() {
508 state.lines.push(String::new());
509 }
510 let old_lines = state.lines.clone();
511 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
512 state.cursor_col = state
513 .cursor_col
514 .min(state.lines[state.cursor_row].chars().count());
515
516 let focused = self.register_focusable();
517 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
518 let wrapping = state.wrap_width.is_some();
519
520 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
521
522 if focused {
523 let mut consumed_indices = Vec::new();
524 for (i, event) in self.events.iter().enumerate() {
525 if let Event::Key(key) = event {
526 if key.kind != KeyEventKind::Press {
527 continue;
528 }
529 match key.code {
530 KeyCode::Char(ch) => {
531 if let Some(max) = state.max_length {
532 let total: usize =
533 state.lines.iter().map(|line| line.chars().count()).sum();
534 if total >= max {
535 continue;
536 }
537 }
538 let index = byte_index_for_char(
539 &state.lines[state.cursor_row],
540 state.cursor_col,
541 );
542 state.lines[state.cursor_row].insert(index, ch);
543 state.cursor_col += 1;
544 consumed_indices.push(i);
545 }
546 KeyCode::Enter => {
547 let split_index = byte_index_for_char(
548 &state.lines[state.cursor_row],
549 state.cursor_col,
550 );
551 let remainder = state.lines[state.cursor_row].split_off(split_index);
552 state.cursor_row += 1;
553 state.lines.insert(state.cursor_row, remainder);
554 state.cursor_col = 0;
555 consumed_indices.push(i);
556 }
557 KeyCode::Backspace => {
558 if state.cursor_col > 0 {
559 let start = byte_index_for_char(
560 &state.lines[state.cursor_row],
561 state.cursor_col - 1,
562 );
563 let end = byte_index_for_char(
564 &state.lines[state.cursor_row],
565 state.cursor_col,
566 );
567 state.lines[state.cursor_row].replace_range(start..end, "");
568 state.cursor_col -= 1;
569 } else if state.cursor_row > 0 {
570 let current = state.lines.remove(state.cursor_row);
571 state.cursor_row -= 1;
572 state.cursor_col = state.lines[state.cursor_row].chars().count();
573 state.lines[state.cursor_row].push_str(¤t);
574 }
575 consumed_indices.push(i);
576 }
577 KeyCode::Left => {
578 if state.cursor_col > 0 {
579 state.cursor_col -= 1;
580 } else if state.cursor_row > 0 {
581 state.cursor_row -= 1;
582 state.cursor_col = state.lines[state.cursor_row].chars().count();
583 }
584 consumed_indices.push(i);
585 }
586 KeyCode::Right => {
587 let line_len = state.lines[state.cursor_row].chars().count();
588 if state.cursor_col < line_len {
589 state.cursor_col += 1;
590 } else if state.cursor_row + 1 < state.lines.len() {
591 state.cursor_row += 1;
592 state.cursor_col = 0;
593 }
594 consumed_indices.push(i);
595 }
596 KeyCode::Up => {
597 if wrapping {
598 let (vrow, vcol) = textarea_logical_to_visual(
599 &pre_vlines,
600 state.cursor_row,
601 state.cursor_col,
602 );
603 if vrow > 0 {
604 let (lr, lc) =
605 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
606 state.cursor_row = lr;
607 state.cursor_col = lc;
608 }
609 } else if state.cursor_row > 0 {
610 state.cursor_row -= 1;
611 state.cursor_col = state
612 .cursor_col
613 .min(state.lines[state.cursor_row].chars().count());
614 }
615 consumed_indices.push(i);
616 }
617 KeyCode::Down => {
618 if wrapping {
619 let (vrow, vcol) = textarea_logical_to_visual(
620 &pre_vlines,
621 state.cursor_row,
622 state.cursor_col,
623 );
624 if vrow + 1 < pre_vlines.len() {
625 let (lr, lc) =
626 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
627 state.cursor_row = lr;
628 state.cursor_col = lc;
629 }
630 } else if state.cursor_row + 1 < state.lines.len() {
631 state.cursor_row += 1;
632 state.cursor_col = state
633 .cursor_col
634 .min(state.lines[state.cursor_row].chars().count());
635 }
636 consumed_indices.push(i);
637 }
638 KeyCode::Home => {
639 state.cursor_col = 0;
640 consumed_indices.push(i);
641 }
642 KeyCode::Delete => {
643 let line_len = state.lines[state.cursor_row].chars().count();
644 if state.cursor_col < line_len {
645 let start = byte_index_for_char(
646 &state.lines[state.cursor_row],
647 state.cursor_col,
648 );
649 let end = byte_index_for_char(
650 &state.lines[state.cursor_row],
651 state.cursor_col + 1,
652 );
653 state.lines[state.cursor_row].replace_range(start..end, "");
654 } else if state.cursor_row + 1 < state.lines.len() {
655 let next = state.lines.remove(state.cursor_row + 1);
656 state.lines[state.cursor_row].push_str(&next);
657 }
658 consumed_indices.push(i);
659 }
660 KeyCode::End => {
661 state.cursor_col = state.lines[state.cursor_row].chars().count();
662 consumed_indices.push(i);
663 }
664 _ => {}
665 }
666 }
667 if let Event::Paste(ref text) = event {
668 for ch in text.chars() {
669 if ch == '\n' || ch == '\r' {
670 let split_index = byte_index_for_char(
671 &state.lines[state.cursor_row],
672 state.cursor_col,
673 );
674 let remainder = state.lines[state.cursor_row].split_off(split_index);
675 state.cursor_row += 1;
676 state.lines.insert(state.cursor_row, remainder);
677 state.cursor_col = 0;
678 } else {
679 if let Some(max) = state.max_length {
680 let total: usize =
681 state.lines.iter().map(|l| l.chars().count()).sum();
682 if total >= max {
683 break;
684 }
685 }
686 let index = byte_index_for_char(
687 &state.lines[state.cursor_row],
688 state.cursor_col,
689 );
690 state.lines[state.cursor_row].insert(index, ch);
691 state.cursor_col += 1;
692 }
693 }
694 consumed_indices.push(i);
695 }
696 }
697
698 for index in consumed_indices {
699 self.consumed[index] = true;
700 }
701 }
702
703 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
704 let (cursor_vrow, cursor_vcol) =
705 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
706
707 if cursor_vrow < state.scroll_offset {
708 state.scroll_offset = cursor_vrow;
709 }
710 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
711 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
712 }
713
714 let interaction_id = self.next_interaction_id();
715 let mut response = self.response_for(interaction_id);
716 response.focused = focused;
717 self.commands.push(Command::BeginContainer {
718 direction: Direction::Column,
719 gap: 0,
720 align: Align::Start,
721 align_self: None,
722 justify: Justify::Start,
723 border: None,
724 border_sides: BorderSides::all(),
725 border_style: Style::new().fg(self.theme.border),
726 bg_color: None,
727 padding: Padding::default(),
728 margin: Margin::default(),
729 constraints: Constraints::default(),
730 title: None,
731 grow: 0,
732 group_name: None,
733 });
734
735 for vi in 0..visible_rows as usize {
736 let actual_vi = state.scroll_offset + vi;
737 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
738 let line = &state.lines[vl.logical_row];
739 let text: String = line
740 .chars()
741 .skip(vl.char_start)
742 .take(vl.char_count)
743 .collect();
744 (text, actual_vi == cursor_vrow)
745 } else {
746 (String::new(), false)
747 };
748
749 let mut rendered = seg_text.clone();
750 let mut style = if seg_text.is_empty() {
751 Style::new().fg(self.theme.text_dim)
752 } else {
753 Style::new().fg(self.theme.text)
754 };
755
756 if is_cursor_line && focused {
757 rendered.clear();
758 for (idx, ch) in seg_text.chars().enumerate() {
759 if idx == cursor_vcol {
760 rendered.push('▎');
761 }
762 rendered.push(ch);
763 }
764 if cursor_vcol >= seg_text.chars().count() {
765 rendered.push('▎');
766 }
767 style = Style::new().fg(self.theme.text);
768 }
769
770 self.styled(rendered, style);
771 }
772 self.commands.push(Command::EndContainer);
773 self.last_text_idx = None;
774
775 response.changed = state.lines != old_lines;
776 response
777 }
778
779 pub fn progress(&mut self, ratio: f64) -> &mut Self {
784 self.progress_bar(ratio, 20)
785 }
786
787 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
793 self.progress_bar_colored(ratio, width, self.theme.primary)
794 }
795
796 pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> &mut Self {
798 let clamped = ratio.clamp(0.0, 1.0);
799 let filled = (clamped * width as f64).round() as u32;
800 let empty = width.saturating_sub(filled);
801 let mut bar = String::new();
802 for _ in 0..filled {
803 bar.push('█');
804 }
805 for _ in 0..empty {
806 bar.push('░');
807 }
808 self.styled(bar, Style::new().fg(color))
809 }
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815 use crate::{EventBuilder, KeyCode, TestBackend};
816
817 #[test]
818 fn text_input_shows_matched_suggestions_for_prefix() {
819 let mut backend = TestBackend::new(40, 10);
820 let mut input = TextInputState::new();
821 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
822
823 let events = EventBuilder::new().key('h').key('e').key('l').build();
824 backend.run_with_events(events, |ui| {
825 let _ = ui.text_input(&mut input);
826 });
827
828 backend.assert_contains("hello");
829 backend.assert_contains("help");
830 assert!(!backend.to_string_trimmed().contains("world"));
831 assert_eq!(input.matched_suggestions().len(), 2);
832 }
833
834 #[test]
835 fn text_input_tab_accepts_top_suggestion() {
836 let mut backend = TestBackend::new(40, 10);
837 let mut input = TextInputState::new();
838 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
839
840 let events = EventBuilder::new()
841 .key('h')
842 .key('e')
843 .key('l')
844 .key_code(KeyCode::Tab)
845 .build();
846 backend.run_with_events(events, |ui| {
847 let _ = ui.text_input(&mut input);
848 });
849
850 assert_eq!(input.value, "hello");
851 assert!(!input.show_suggestions);
852 }
853
854 #[test]
855 fn text_input_empty_value_shows_no_suggestions() {
856 let mut backend = TestBackend::new(40, 10);
857 let mut input = TextInputState::new();
858 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
859
860 backend.render(|ui| {
861 let _ = ui.text_input(&mut input);
862 });
863
864 let rendered = backend.to_string_trimmed();
865 assert!(!rendered.contains("hello"));
866 assert!(!rendered.contains("help"));
867 assert!(!rendered.contains("world"));
868 assert!(input.matched_suggestions().is_empty());
869 assert!(!input.show_suggestions);
870 }
871}