Skip to main content

slt/context/
widgets_input.rs

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