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) if !has_global_shortcut_modifier(key.modifiers) => {
102                        if let Some(max) = state.max_length
103                            && grapheme_count(&state.value) >= max
104                        {
105                            continue;
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                let current_len = grapheme_count(&state.value);
186                let available = state
187                    .max_length
188                    .map(|max| max.saturating_sub(current_len))
189                    .unwrap_or(usize::MAX);
190                let inserted = text
191                    .graphemes(true)
192                    .filter(|cluster| {
193                        cluster
194                            .chars()
195                            .all(|ch| (ch as u32) >= 0x20 && ch != '\u{7f}')
196                    })
197                    .take(available)
198                    .collect::<String>();
199                if !inserted.is_empty() {
200                    let index = byte_index_for_grapheme(&state.value, state.cursor);
201                    let inserted_end = index + inserted.len();
202                    state.value.insert_str(index, &inserted);
203                    state.cursor = grapheme_count(&state.value[..inserted_end]);
204                    if !state.suggestions.is_empty() {
205                        state.show_suggestions = true;
206                        state.suggestion_index = 0;
207                    }
208                    suggestions_dirty = true;
209                }
210                consumed_indices.push(i);
211            }
212            // Suppress unused-assignment warning when no key after last paste.
213            let _ = suggestions_dirty;
214
215            self.consume_indices(consumed_indices);
216        }
217
218        if state.value.is_empty() {
219            state.show_suggestions = false;
220            state.suggestion_index = 0;
221        }
222
223        let matched_suggestions = if state.show_suggestions {
224            state
225                .matched_suggestions()
226                .into_iter()
227                .map(str::to_string)
228                .collect::<Vec<String>>()
229        } else {
230            Vec::new()
231        };
232        if !matched_suggestions.is_empty() {
233            state.suggestion_index = state
234                .suggestion_index
235                .min(matched_suggestions.len().saturating_sub(1));
236        }
237
238        let visible_width = self.area_width.saturating_sub(4) as usize;
239        let (input_text, cursor_offset) = if state.value.is_empty() {
240            if state.placeholder.len() > 100 {
241                slt_warn(
242                    "text_input placeholder is very long (>100 chars) — consider shortening it",
243                );
244            }
245            let mut ph = state.placeholder.clone();
246            if focused {
247                ph.insert(0, '▎');
248                (ph, Some(0))
249            } else {
250                (ph, None)
251            }
252        } else {
253            // Display units are grapheme clusters: `state.cursor` is a cluster
254            // index, so each rendered unit (one source cluster, or one mask
255            // glyph standing in for it) advances the cursor index by one.
256            let clusters: Vec<&str> = state.value.graphemes(true).collect();
257            let display_units: Vec<&str> = if state.masked {
258                vec!["•"; clusters.len()]
259            } else {
260                clusters.clone()
261            };
262
263            let cursor_display_pos: usize = display_units[..state.cursor.min(display_units.len())]
264                .iter()
265                .map(|g| cluster_width(g).max(1) as usize)
266                .sum();
267
268            let scroll_offset = if cursor_display_pos >= visible_width {
269                cursor_display_pos - visible_width + 1
270            } else {
271                0
272            };
273
274            let mut rendered = String::new();
275            let mut cursor_offset = None;
276            let mut current_width: usize = 0;
277            for (idx, g) in display_units.iter().enumerate() {
278                let cw = cluster_width(g).max(1) as usize;
279                if current_width + cw <= scroll_offset {
280                    current_width += cw;
281                    continue;
282                }
283                if current_width - scroll_offset >= visible_width {
284                    break;
285                }
286                if focused && idx == state.cursor {
287                    cursor_offset = Some(rendered.chars().count());
288                    rendered.push('▎');
289                }
290                rendered.push_str(g);
291                current_width += cw;
292            }
293            if focused && state.cursor >= display_units.len() {
294                cursor_offset = Some(rendered.chars().count());
295                rendered.push('▎');
296            }
297            (rendered, cursor_offset)
298        };
299        let input_style = if state.value.is_empty() && !focused {
300            Style::new()
301                .dim()
302                .fg(colors.fg.unwrap_or(self.theme.text_dim))
303        } else {
304            Style::new().fg(colors.fg.unwrap_or(self.theme.text))
305        };
306
307        let border_color = if focused {
308            colors.accent.unwrap_or(self.theme.primary)
309        } else if state.validation_error.is_some() {
310            colors.accent.unwrap_or(self.theme.error)
311        } else {
312            colors.border.unwrap_or(self.theme.border)
313        };
314
315        let input_padx = self.theme.spacing.xs();
316        let mut response = self
317            .bordered(Border::Rounded)
318            .border_style(Style::new().fg(border_color))
319            .px(input_padx)
320            .col(|ui| {
321                ui.styled_with_cursor(input_text, input_style, cursor_offset);
322            });
323        response.focused = focused;
324        response.changed = state.value != old_value;
325        response.gained_focus = gained_focus;
326        response.lost_focus = lost_focus;
327        response.submitted = submitted;
328
329        let errors = state.errors();
330        if !errors.is_empty() {
331            for error in errors {
332                let mut warning = String::with_capacity(2 + error.len());
333                warning.push_str("⚠ ");
334                warning.push_str(error);
335                self.styled(
336                    warning,
337                    Style::new()
338                        .dim()
339                        .fg(colors.accent.unwrap_or(self.theme.error)),
340                );
341            }
342        } else if let Some(error) = state.validation_error.clone() {
343            let mut warning = String::with_capacity(2 + error.len());
344            warning.push_str("⚠ ");
345            warning.push_str(&error);
346            self.styled(
347                warning,
348                Style::new()
349                    .dim()
350                    .fg(colors.accent.unwrap_or(self.theme.error)),
351            );
352        }
353
354        if state.show_suggestions && !matched_suggestions.is_empty() {
355            let start = state.suggestion_index.saturating_sub(4);
356            let end = (start + 5).min(matched_suggestions.len());
357            let suggestion_border = colors.border.unwrap_or(self.theme.border);
358            let suggestion_padx = self.theme.spacing.xs();
359            let _ = self
360                .bordered(Border::Rounded)
361                .border_style(Style::new().fg(suggestion_border))
362                .px(suggestion_padx)
363                .col(|ui| {
364                    for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
365                        let actual_idx = start + idx;
366                        if actual_idx == state.suggestion_index {
367                            ui.styled(
368                                suggestion.clone(),
369                                Style::new()
370                                    .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
371                                    .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
372                            );
373                        } else {
374                            ui.styled(
375                                suggestion.clone(),
376                                Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
377                            );
378                        }
379                    }
380                });
381        }
382        response
383    }
384}