Skip to main content

slt/context/widgets_input/
text_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        let colors = self.widget_theme.text_input;
21        self.text_input_colored(state, &colors)
22    }
23
24    /// Render a text input with custom widget colors.
25    pub fn text_input_colored(
26        &mut self,
27        state: &mut TextInputState,
28        colors: &WidgetColors,
29    ) -> Response {
30        slt_assert(
31            !state.value.contains('\n'),
32            "text_input got a newline — use textarea instead",
33        );
34        let focused = self.register_focusable();
35        let old_value = state.value.clone();
36        state.cursor = state.cursor.min(state.value.chars().count());
37
38        if focused {
39            let mut consumed_indices = Vec::new();
40            for (i, key) in self.available_key_presses() {
41                let matched_suggestions = if state.show_suggestions {
42                    state
43                        .matched_suggestions()
44                        .into_iter()
45                        .map(str::to_string)
46                        .collect::<Vec<String>>()
47                } else {
48                    Vec::new()
49                };
50                let suggestions_visible = !matched_suggestions.is_empty();
51                if suggestions_visible {
52                    state.suggestion_index = state
53                        .suggestion_index
54                        .min(matched_suggestions.len().saturating_sub(1));
55                }
56                match key.code {
57                    KeyCode::Up if suggestions_visible => {
58                        state.suggestion_index = state.suggestion_index.saturating_sub(1);
59                        consumed_indices.push(i);
60                    }
61                    KeyCode::Down if suggestions_visible => {
62                        state.suggestion_index = (state.suggestion_index + 1)
63                            .min(matched_suggestions.len().saturating_sub(1));
64                        consumed_indices.push(i);
65                    }
66                    KeyCode::Esc if state.show_suggestions => {
67                        state.show_suggestions = false;
68                        state.suggestion_index = 0;
69                        consumed_indices.push(i);
70                    }
71                    KeyCode::Tab if suggestions_visible => {
72                        if let Some(selected) = matched_suggestions
73                            .get(state.suggestion_index)
74                            .or_else(|| matched_suggestions.first())
75                        {
76                            state.value = selected.clone();
77                            state.cursor = state.value.chars().count();
78                            state.show_suggestions = false;
79                            state.suggestion_index = 0;
80                        }
81                        consumed_indices.push(i);
82                    }
83                    KeyCode::Char(ch) => {
84                        if let Some(max) = state.max_length {
85                            if state.value.chars().count() >= max {
86                                continue;
87                            }
88                        }
89                        let index = byte_index_for_char(&state.value, state.cursor);
90                        state.value.insert(index, ch);
91                        state.cursor += 1;
92                        if !state.suggestions.is_empty() {
93                            state.show_suggestions = true;
94                            state.suggestion_index = 0;
95                        }
96                        consumed_indices.push(i);
97                    }
98                    KeyCode::Backspace => {
99                        if state.cursor > 0 {
100                            let start = byte_index_for_char(&state.value, state.cursor - 1);
101                            let end = byte_index_for_char(&state.value, state.cursor);
102                            state.value.replace_range(start..end, "");
103                            state.cursor -= 1;
104                        }
105                        if !state.suggestions.is_empty() {
106                            state.show_suggestions = true;
107                            state.suggestion_index = 0;
108                        }
109                        consumed_indices.push(i);
110                    }
111                    KeyCode::Left => {
112                        state.cursor = state.cursor.saturating_sub(1);
113                        consumed_indices.push(i);
114                    }
115                    KeyCode::Right => {
116                        state.cursor = (state.cursor + 1).min(state.value.chars().count());
117                        consumed_indices.push(i);
118                    }
119                    KeyCode::Home => {
120                        state.cursor = 0;
121                        consumed_indices.push(i);
122                    }
123                    KeyCode::Delete => {
124                        let len = state.value.chars().count();
125                        if state.cursor < len {
126                            let start = byte_index_for_char(&state.value, state.cursor);
127                            let end = byte_index_for_char(&state.value, state.cursor + 1);
128                            state.value.replace_range(start..end, "");
129                        }
130                        if !state.suggestions.is_empty() {
131                            state.show_suggestions = true;
132                            state.suggestion_index = 0;
133                        }
134                        consumed_indices.push(i);
135                    }
136                    KeyCode::End => {
137                        state.cursor = state.value.chars().count();
138                        consumed_indices.push(i);
139                    }
140                    _ => {}
141                }
142            }
143            for (i, text) in self.available_pastes() {
144                // Cache char count once and update incrementally — insert is
145                // O(1) amortized per char, so recomputing via `chars().count()`
146                // inside the loop would be O(n²) on large pastes.
147                let mut char_count = state.value.chars().count();
148                for ch in text.chars() {
149                    // text_input is single-line; drop newlines, tabs, control
150                    // chars, and other bytes that would corrupt rendering or
151                    // trip the no-newline invariant upstream.
152                    if (ch as u32) < 0x20 || ch == '\u{7f}' {
153                        continue;
154                    }
155                    if let Some(max) = state.max_length {
156                        if char_count >= max {
157                            break;
158                        }
159                    }
160                    let index = byte_index_for_char(&state.value, state.cursor);
161                    state.value.insert(index, ch);
162                    state.cursor += 1;
163                    char_count += 1;
164                }
165                if !state.suggestions.is_empty() {
166                    state.show_suggestions = true;
167                    state.suggestion_index = 0;
168                }
169                consumed_indices.push(i);
170            }
171
172            self.consume_indices(consumed_indices);
173        }
174
175        if state.value.is_empty() {
176            state.show_suggestions = false;
177            state.suggestion_index = 0;
178        }
179
180        let matched_suggestions = if state.show_suggestions {
181            state
182                .matched_suggestions()
183                .into_iter()
184                .map(str::to_string)
185                .collect::<Vec<String>>()
186        } else {
187            Vec::new()
188        };
189        if !matched_suggestions.is_empty() {
190            state.suggestion_index = state
191                .suggestion_index
192                .min(matched_suggestions.len().saturating_sub(1));
193        }
194
195        let visible_width = self.area_width.saturating_sub(4) as usize;
196        let (input_text, cursor_offset) = if state.value.is_empty() {
197            if state.placeholder.len() > 100 {
198                slt_warn(
199                    "text_input placeholder is very long (>100 chars) — consider shortening it",
200                );
201            }
202            let mut ph = state.placeholder.clone();
203            if focused {
204                ph.insert(0, '▎');
205                (ph, Some(0))
206            } else {
207                (ph, None)
208            }
209        } else {
210            let chars: Vec<char> = state.value.chars().collect();
211            let display_chars: Vec<char> = if state.masked {
212                vec!['•'; chars.len()]
213            } else {
214                chars.clone()
215            };
216
217            let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
218                .iter()
219                .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
220                .sum();
221
222            let scroll_offset = if cursor_display_pos >= visible_width {
223                cursor_display_pos - visible_width + 1
224            } else {
225                0
226            };
227
228            let mut rendered = String::new();
229            let mut cursor_offset = None;
230            let mut current_width: usize = 0;
231            for (idx, &ch) in display_chars.iter().enumerate() {
232                let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
233                if current_width + cw <= scroll_offset {
234                    current_width += cw;
235                    continue;
236                }
237                if current_width - scroll_offset >= visible_width {
238                    break;
239                }
240                if focused && idx == state.cursor {
241                    cursor_offset = Some(rendered.chars().count());
242                    rendered.push('▎');
243                }
244                rendered.push(ch);
245                current_width += cw;
246            }
247            if focused && state.cursor >= display_chars.len() {
248                cursor_offset = Some(rendered.chars().count());
249                rendered.push('▎');
250            }
251            (rendered, cursor_offset)
252        };
253        let input_style = if state.value.is_empty() && !focused {
254            Style::new()
255                .dim()
256                .fg(colors.fg.unwrap_or(self.theme.text_dim))
257        } else {
258            Style::new().fg(colors.fg.unwrap_or(self.theme.text))
259        };
260
261        let border_color = if focused {
262            colors.accent.unwrap_or(self.theme.primary)
263        } else if state.validation_error.is_some() {
264            colors.accent.unwrap_or(self.theme.error)
265        } else {
266            colors.border.unwrap_or(self.theme.border)
267        };
268
269        let mut response = self
270            .bordered(Border::Rounded)
271            .border_style(Style::new().fg(border_color))
272            .px(1)
273            .grow(1)
274            .col(|ui| {
275                ui.styled_with_cursor(input_text, input_style, cursor_offset);
276            });
277        response.focused = focused;
278        response.changed = state.value != old_value;
279
280        let errors = state.errors();
281        if !errors.is_empty() {
282            for error in errors {
283                let mut warning = String::with_capacity(2 + error.len());
284                warning.push_str("⚠ ");
285                warning.push_str(error);
286                self.styled(
287                    warning,
288                    Style::new()
289                        .dim()
290                        .fg(colors.accent.unwrap_or(self.theme.error)),
291                );
292            }
293        } else if let Some(error) = state.validation_error.clone() {
294            let mut warning = String::with_capacity(2 + error.len());
295            warning.push_str("⚠ ");
296            warning.push_str(&error);
297            self.styled(
298                warning,
299                Style::new()
300                    .dim()
301                    .fg(colors.accent.unwrap_or(self.theme.error)),
302            );
303        }
304
305        if state.show_suggestions && !matched_suggestions.is_empty() {
306            let start = state.suggestion_index.saturating_sub(4);
307            let end = (start + 5).min(matched_suggestions.len());
308            let suggestion_border = colors.border.unwrap_or(self.theme.border);
309            let _ = self
310                .bordered(Border::Rounded)
311                .border_style(Style::new().fg(suggestion_border))
312                .px(1)
313                .col(|ui| {
314                    for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
315                        let actual_idx = start + idx;
316                        if actual_idx == state.suggestion_index {
317                            ui.styled(
318                                suggestion.clone(),
319                                Style::new()
320                                    .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
321                                    .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
322                            );
323                        } else {
324                            ui.styled(
325                                suggestion.clone(),
326                                Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
327                            );
328                        }
329                    }
330                });
331        }
332        response
333    }
334}