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