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    /// Render a text input with custom widget colors.
24    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    /// Render an animated spinner.
326    ///
327    /// The spinner advances one frame per tick. Use [`SpinnerState::dots`] or
328    /// [`SpinnerState::line`] to create the state, then chain style methods to
329    /// color it.
330    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    /// Render toast notifications. Calls `state.cleanup(tick)` automatically.
338    ///
339    /// Expired messages are removed before rendering. If there are no active
340    /// messages, nothing is rendered and `self` is returned unchanged.
341    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    /// Horizontal slider for numeric values.
384    ///
385    /// # Examples
386    /// ```
387    /// # use slt::*;
388    /// # TestBackend::new(80, 24).render(|ui| {
389    /// let mut volume = 75.0_f64;
390    /// let r = ui.slider("Volume", &mut volume, 0.0..=100.0);
391    /// if r.changed { /* volume was adjusted */ }
392    /// # });
393    /// ```
394    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    /// Render a multi-line text area with the given number of visible rows.
500    ///
501    /// When focused, handles character input, Enter (new line), Backspace,
502    /// arrow keys, Home, and End. The cursor is rendered as a block character.
503    ///
504    /// Set [`TextareaState::word_wrap`] to enable soft-wrapping at a given
505    /// display-column width. Up/Down then navigate visual lines.
506    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(&current);
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    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
780    ///
781    /// Uses block characters (`█` filled, `░` empty). For a custom width use
782    /// [`Context::progress_bar`].
783    pub fn progress(&mut self, ratio: f64) -> &mut Self {
784        self.progress_bar(ratio, 20)
785    }
786
787    /// Render a progress bar with a custom character width.
788    ///
789    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
790    /// characters rendered.
791    /// Render a progress bar filled to the given ratio (0.0–1.0).
792    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    /// Render a progress bar with a custom fill color.
797    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}