slt/context/widgets_input/
text_input.rs1use super::*;
2
3impl Context {
4 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 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 (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 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 && 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 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 mut char_count = grapheme_count(&state.value);
189 for ch in text.chars() {
190 if (ch as u32) < 0x20 || ch == '\u{7f}' {
194 continue;
195 }
196 if let Some(max) = state.max_length
197 && char_count >= max
198 {
199 break;
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 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 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}