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(grapheme_count(&state.value));
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 = grapheme_count(&state.value);
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 grapheme_count(&state.value) >= max {
97                                continue;
98                            }
99                        }
100                        let index = byte_index_for_grapheme(&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_grapheme(&state.value, state.cursor - 1);
113                            let end = byte_index_for_grapheme(&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(grapheme_count(&state.value));
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 = grapheme_count(&state.value);
138                        if state.cursor < len {
139                            let start = byte_index_for_grapheme(&state.value, state.cursor);
140                            let end = byte_index_for_grapheme(&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 = grapheme_count(&state.value);
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 = grapheme_count(&state.value);
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_grapheme(&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            // Display units are grapheme clusters: `state.cursor` is a cluster
228            // index, so each rendered unit (one source cluster, or one mask
229            // glyph standing in for it) advances the cursor index by one.
230            let clusters: Vec<&str> = state.value.graphemes(true).collect();
231            let display_units: Vec<&str> = if state.masked {
232                vec!["•"; clusters.len()]
233            } else {
234                clusters.clone()
235            };
236
237            let cursor_display_pos: usize = display_units[..state.cursor.min(display_units.len())]
238                .iter()
239                .map(|g| cluster_width(g).max(1) as usize)
240                .sum();
241
242            let scroll_offset = if cursor_display_pos >= visible_width {
243                cursor_display_pos - visible_width + 1
244            } else {
245                0
246            };
247
248            let mut rendered = String::new();
249            let mut cursor_offset = None;
250            let mut current_width: usize = 0;
251            for (idx, g) in display_units.iter().enumerate() {
252                let cw = cluster_width(g).max(1) as usize;
253                if current_width + cw <= scroll_offset {
254                    current_width += cw;
255                    continue;
256                }
257                if current_width - scroll_offset >= visible_width {
258                    break;
259                }
260                if focused && idx == state.cursor {
261                    cursor_offset = Some(rendered.chars().count());
262                    rendered.push('▎');
263                }
264                rendered.push_str(g);
265                current_width += cw;
266            }
267            if focused && state.cursor >= display_units.len() {
268                cursor_offset = Some(rendered.chars().count());
269                rendered.push('▎');
270            }
271            (rendered, cursor_offset)
272        };
273        let input_style = if state.value.is_empty() && !focused {
274            Style::new()
275                .dim()
276                .fg(colors.fg.unwrap_or(self.theme.text_dim))
277        } else {
278            Style::new().fg(colors.fg.unwrap_or(self.theme.text))
279        };
280
281        let border_color = if focused {
282            colors.accent.unwrap_or(self.theme.primary)
283        } else if state.validation_error.is_some() {
284            colors.accent.unwrap_or(self.theme.error)
285        } else {
286            colors.border.unwrap_or(self.theme.border)
287        };
288
289        let input_padx = self.theme.spacing.xs();
290        let mut response = self
291            .bordered(Border::Rounded)
292            .border_style(Style::new().fg(border_color))
293            .px(input_padx)
294            .col(|ui| {
295                ui.styled_with_cursor(input_text, input_style, cursor_offset);
296            });
297        response.focused = focused;
298        response.changed = state.value != old_value;
299
300        let errors = state.errors();
301        if !errors.is_empty() {
302            for error in errors {
303                let mut warning = String::with_capacity(2 + error.len());
304                warning.push_str("⚠ ");
305                warning.push_str(error);
306                self.styled(
307                    warning,
308                    Style::new()
309                        .dim()
310                        .fg(colors.accent.unwrap_or(self.theme.error)),
311                );
312            }
313        } else if let Some(error) = state.validation_error.clone() {
314            let mut warning = String::with_capacity(2 + error.len());
315            warning.push_str("⚠ ");
316            warning.push_str(&error);
317            self.styled(
318                warning,
319                Style::new()
320                    .dim()
321                    .fg(colors.accent.unwrap_or(self.theme.error)),
322            );
323        }
324
325        if state.show_suggestions && !matched_suggestions.is_empty() {
326            let start = state.suggestion_index.saturating_sub(4);
327            let end = (start + 5).min(matched_suggestions.len());
328            let suggestion_border = colors.border.unwrap_or(self.theme.border);
329            let suggestion_padx = self.theme.spacing.xs();
330            let _ = self
331                .bordered(Border::Rounded)
332                .border_style(Style::new().fg(suggestion_border))
333                .px(suggestion_padx)
334                .col(|ui| {
335                    for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
336                        let actual_idx = start + idx;
337                        if actual_idx == state.suggestion_index {
338                            ui.styled(
339                                suggestion.clone(),
340                                Style::new()
341                                    .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
342                                    .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
343                            );
344                        } else {
345                            ui.styled(
346                                suggestion.clone(),
347                                Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
348                            );
349                        }
350                    }
351                });
352        }
353        response
354    }
355}