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