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