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