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