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