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) 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 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 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 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}