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