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(state.value.chars().count());
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 = state.value.chars().count();
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 state.value.chars().count() >= max {
97 continue;
98 }
99 }
100 let index = byte_index_for_char(&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_char(&state.value, state.cursor - 1);
113 let end = byte_index_for_char(&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(state.value.chars().count());
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 = state.value.chars().count();
138 if state.cursor < len {
139 let start = byte_index_for_char(&state.value, state.cursor);
140 let end = byte_index_for_char(&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 = state.value.chars().count();
152 consumed_indices.push(i);
153 }
154 _ => {}
155 }
156 }
157 for (i, text) in self.available_pastes() {
158 let mut char_count = state.value.chars().count();
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_char(&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 chars: Vec<char> = state.value.chars().collect();
228 let display_chars: Vec<char> = if state.masked {
229 vec!['•'; chars.len()]
230 } else {
231 chars.clone()
232 };
233
234 let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
235 .iter()
236 .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
237 .sum();
238
239 let scroll_offset = if cursor_display_pos >= visible_width {
240 cursor_display_pos - visible_width + 1
241 } else {
242 0
243 };
244
245 let mut rendered = String::new();
246 let mut cursor_offset = None;
247 let mut current_width: usize = 0;
248 for (idx, &ch) in display_chars.iter().enumerate() {
249 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
250 if current_width + cw <= scroll_offset {
251 current_width += cw;
252 continue;
253 }
254 if current_width - scroll_offset >= visible_width {
255 break;
256 }
257 if focused && idx == state.cursor {
258 cursor_offset = Some(rendered.chars().count());
259 rendered.push('▎');
260 }
261 rendered.push(ch);
262 current_width += cw;
263 }
264 if focused && state.cursor >= display_chars.len() {
265 cursor_offset = Some(rendered.chars().count());
266 rendered.push('▎');
267 }
268 (rendered, cursor_offset)
269 };
270 let input_style = if state.value.is_empty() && !focused {
271 Style::new()
272 .dim()
273 .fg(colors.fg.unwrap_or(self.theme.text_dim))
274 } else {
275 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
276 };
277
278 let border_color = if focused {
279 colors.accent.unwrap_or(self.theme.primary)
280 } else if state.validation_error.is_some() {
281 colors.accent.unwrap_or(self.theme.error)
282 } else {
283 colors.border.unwrap_or(self.theme.border)
284 };
285
286 let input_padx = self.theme.spacing.xs();
287 let mut response = self
288 .bordered(Border::Rounded)
289 .border_style(Style::new().fg(border_color))
290 .px(input_padx)
291 .col(|ui| {
292 ui.styled_with_cursor(input_text, input_style, cursor_offset);
293 });
294 response.focused = focused;
295 response.changed = state.value != old_value;
296
297 let errors = state.errors();
298 if !errors.is_empty() {
299 for error in errors {
300 let mut warning = String::with_capacity(2 + error.len());
301 warning.push_str("⚠ ");
302 warning.push_str(error);
303 self.styled(
304 warning,
305 Style::new()
306 .dim()
307 .fg(colors.accent.unwrap_or(self.theme.error)),
308 );
309 }
310 } else if let Some(error) = state.validation_error.clone() {
311 let mut warning = String::with_capacity(2 + error.len());
312 warning.push_str("⚠ ");
313 warning.push_str(&error);
314 self.styled(
315 warning,
316 Style::new()
317 .dim()
318 .fg(colors.accent.unwrap_or(self.theme.error)),
319 );
320 }
321
322 if state.show_suggestions && !matched_suggestions.is_empty() {
323 let start = state.suggestion_index.saturating_sub(4);
324 let end = (start + 5).min(matched_suggestions.len());
325 let suggestion_border = colors.border.unwrap_or(self.theme.border);
326 let suggestion_padx = self.theme.spacing.xs();
327 let _ = self
328 .bordered(Border::Rounded)
329 .border_style(Style::new().fg(suggestion_border))
330 .px(suggestion_padx)
331 .col(|ui| {
332 for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
333 let actual_idx = start + idx;
334 if actual_idx == state.suggestion_index {
335 ui.styled(
336 suggestion.clone(),
337 Style::new()
338 .bg(colors.accent.unwrap_or(ui.theme().selected_bg))
339 .fg(colors.fg.unwrap_or(ui.theme().selected_fg)),
340 );
341 } else {
342 ui.styled(
343 suggestion.clone(),
344 Style::new().fg(colors.fg.unwrap_or(ui.theme().text)),
345 );
346 }
347 }
348 });
349 }
350 response
351 }
352}