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