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