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