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