1use super::*;
2
3impl Context {
4 pub fn text_input(&mut self, state: &mut TextInputState) -> &mut Self {
20 slt_assert(
21 !state.value.contains('\n'),
22 "text_input got a newline — use textarea instead",
23 );
24 let focused = self.register_focusable();
25 state.cursor = state.cursor.min(state.value.chars().count());
26
27 if focused {
28 let mut consumed_indices = Vec::new();
29 for (i, event) in self.events.iter().enumerate() {
30 if let Event::Key(key) = event {
31 if key.kind != KeyEventKind::Press {
32 continue;
33 }
34 match key.code {
35 KeyCode::Char(ch) => {
36 if let Some(max) = state.max_length {
37 if state.value.chars().count() >= max {
38 continue;
39 }
40 }
41 let index = byte_index_for_char(&state.value, state.cursor);
42 state.value.insert(index, ch);
43 state.cursor += 1;
44 consumed_indices.push(i);
45 }
46 KeyCode::Backspace => {
47 if state.cursor > 0 {
48 let start = byte_index_for_char(&state.value, state.cursor - 1);
49 let end = byte_index_for_char(&state.value, state.cursor);
50 state.value.replace_range(start..end, "");
51 state.cursor -= 1;
52 }
53 consumed_indices.push(i);
54 }
55 KeyCode::Left => {
56 state.cursor = state.cursor.saturating_sub(1);
57 consumed_indices.push(i);
58 }
59 KeyCode::Right => {
60 state.cursor = (state.cursor + 1).min(state.value.chars().count());
61 consumed_indices.push(i);
62 }
63 KeyCode::Home => {
64 state.cursor = 0;
65 consumed_indices.push(i);
66 }
67 KeyCode::Delete => {
68 let len = state.value.chars().count();
69 if state.cursor < len {
70 let start = byte_index_for_char(&state.value, state.cursor);
71 let end = byte_index_for_char(&state.value, state.cursor + 1);
72 state.value.replace_range(start..end, "");
73 }
74 consumed_indices.push(i);
75 }
76 KeyCode::End => {
77 state.cursor = state.value.chars().count();
78 consumed_indices.push(i);
79 }
80 _ => {}
81 }
82 }
83 if let Event::Paste(ref text) = event {
84 for ch in text.chars() {
85 if let Some(max) = state.max_length {
86 if state.value.chars().count() >= max {
87 break;
88 }
89 }
90 let index = byte_index_for_char(&state.value, state.cursor);
91 state.value.insert(index, ch);
92 state.cursor += 1;
93 }
94 consumed_indices.push(i);
95 }
96 }
97
98 for index in consumed_indices {
99 self.consumed[index] = true;
100 }
101 }
102
103 let visible_width = self.area_width.saturating_sub(4) as usize;
104 let input_text = if state.value.is_empty() {
105 if state.placeholder.len() > 100 {
106 slt_warn(
107 "text_input placeholder is very long (>100 chars) — consider shortening it",
108 );
109 }
110 let mut ph = state.placeholder.clone();
111 if focused {
112 ph.insert(0, '▎');
113 }
114 ph
115 } else {
116 let chars: Vec<char> = state.value.chars().collect();
117 let display_chars: Vec<char> = if state.masked {
118 vec!['•'; chars.len()]
119 } else {
120 chars.clone()
121 };
122
123 let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
124 .iter()
125 .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
126 .sum();
127
128 let scroll_offset = if cursor_display_pos >= visible_width {
129 cursor_display_pos - visible_width + 1
130 } else {
131 0
132 };
133
134 let mut rendered = String::new();
135 let mut current_width: usize = 0;
136 for (idx, &ch) in display_chars.iter().enumerate() {
137 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
138 if current_width + cw <= scroll_offset {
139 current_width += cw;
140 continue;
141 }
142 if current_width - scroll_offset >= visible_width {
143 break;
144 }
145 if focused && idx == state.cursor {
146 rendered.push('▎');
147 }
148 rendered.push(ch);
149 current_width += cw;
150 }
151 if focused && state.cursor >= display_chars.len() {
152 rendered.push('▎');
153 }
154 rendered
155 };
156 let input_style = if state.value.is_empty() && !focused {
157 Style::new().dim().fg(self.theme.text_dim)
158 } else {
159 Style::new().fg(self.theme.text)
160 };
161
162 let border_color = if focused {
163 self.theme.primary
164 } else if state.validation_error.is_some() {
165 self.theme.error
166 } else {
167 self.theme.border
168 };
169
170 self.bordered(Border::Rounded)
171 .border_style(Style::new().fg(border_color))
172 .px(1)
173 .col(|ui| {
174 ui.styled(input_text, input_style);
175 });
176
177 if let Some(error) = state.validation_error.clone() {
178 self.styled(
179 format!("⚠ {error}"),
180 Style::new().dim().fg(self.theme.error),
181 );
182 }
183 self
184 }
185
186 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
192 self.styled(
193 state.frame(self.tick).to_string(),
194 Style::new().fg(self.theme.primary),
195 )
196 }
197
198 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
203 state.cleanup(self.tick);
204 if state.messages.is_empty() {
205 return self;
206 }
207
208 self.interaction_count += 1;
209 self.commands.push(Command::BeginContainer {
210 direction: Direction::Column,
211 gap: 0,
212 align: Align::Start,
213 justify: Justify::Start,
214 border: None,
215 border_sides: BorderSides::all(),
216 border_style: Style::new().fg(self.theme.border),
217 bg_color: None,
218 padding: Padding::default(),
219 margin: Margin::default(),
220 constraints: Constraints::default(),
221 title: None,
222 grow: 0,
223 group_name: None,
224 });
225 for message in state.messages.iter().rev() {
226 let color = match message.level {
227 ToastLevel::Info => self.theme.primary,
228 ToastLevel::Success => self.theme.success,
229 ToastLevel::Warning => self.theme.warning,
230 ToastLevel::Error => self.theme.error,
231 };
232 self.styled(format!(" ● {}", message.text), Style::new().fg(color));
233 }
234 self.commands.push(Command::EndContainer);
235 self.last_text_idx = None;
236
237 self
238 }
239
240 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> &mut Self {
248 if state.lines.is_empty() {
249 state.lines.push(String::new());
250 }
251 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
252 state.cursor_col = state
253 .cursor_col
254 .min(state.lines[state.cursor_row].chars().count());
255
256 let focused = self.register_focusable();
257 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
258 let wrapping = state.wrap_width.is_some();
259
260 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
261
262 if focused {
263 let mut consumed_indices = Vec::new();
264 for (i, event) in self.events.iter().enumerate() {
265 if let Event::Key(key) = event {
266 if key.kind != KeyEventKind::Press {
267 continue;
268 }
269 match key.code {
270 KeyCode::Char(ch) => {
271 if let Some(max) = state.max_length {
272 let total: usize =
273 state.lines.iter().map(|line| line.chars().count()).sum();
274 if total >= max {
275 continue;
276 }
277 }
278 let index = byte_index_for_char(
279 &state.lines[state.cursor_row],
280 state.cursor_col,
281 );
282 state.lines[state.cursor_row].insert(index, ch);
283 state.cursor_col += 1;
284 consumed_indices.push(i);
285 }
286 KeyCode::Enter => {
287 let split_index = byte_index_for_char(
288 &state.lines[state.cursor_row],
289 state.cursor_col,
290 );
291 let remainder = state.lines[state.cursor_row].split_off(split_index);
292 state.cursor_row += 1;
293 state.lines.insert(state.cursor_row, remainder);
294 state.cursor_col = 0;
295 consumed_indices.push(i);
296 }
297 KeyCode::Backspace => {
298 if state.cursor_col > 0 {
299 let start = byte_index_for_char(
300 &state.lines[state.cursor_row],
301 state.cursor_col - 1,
302 );
303 let end = byte_index_for_char(
304 &state.lines[state.cursor_row],
305 state.cursor_col,
306 );
307 state.lines[state.cursor_row].replace_range(start..end, "");
308 state.cursor_col -= 1;
309 } else if state.cursor_row > 0 {
310 let current = state.lines.remove(state.cursor_row);
311 state.cursor_row -= 1;
312 state.cursor_col = state.lines[state.cursor_row].chars().count();
313 state.lines[state.cursor_row].push_str(¤t);
314 }
315 consumed_indices.push(i);
316 }
317 KeyCode::Left => {
318 if state.cursor_col > 0 {
319 state.cursor_col -= 1;
320 } else if state.cursor_row > 0 {
321 state.cursor_row -= 1;
322 state.cursor_col = state.lines[state.cursor_row].chars().count();
323 }
324 consumed_indices.push(i);
325 }
326 KeyCode::Right => {
327 let line_len = state.lines[state.cursor_row].chars().count();
328 if state.cursor_col < line_len {
329 state.cursor_col += 1;
330 } else if state.cursor_row + 1 < state.lines.len() {
331 state.cursor_row += 1;
332 state.cursor_col = 0;
333 }
334 consumed_indices.push(i);
335 }
336 KeyCode::Up => {
337 if wrapping {
338 let (vrow, vcol) = textarea_logical_to_visual(
339 &pre_vlines,
340 state.cursor_row,
341 state.cursor_col,
342 );
343 if vrow > 0 {
344 let (lr, lc) =
345 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
346 state.cursor_row = lr;
347 state.cursor_col = lc;
348 }
349 } else if state.cursor_row > 0 {
350 state.cursor_row -= 1;
351 state.cursor_col = state
352 .cursor_col
353 .min(state.lines[state.cursor_row].chars().count());
354 }
355 consumed_indices.push(i);
356 }
357 KeyCode::Down => {
358 if wrapping {
359 let (vrow, vcol) = textarea_logical_to_visual(
360 &pre_vlines,
361 state.cursor_row,
362 state.cursor_col,
363 );
364 if vrow + 1 < pre_vlines.len() {
365 let (lr, lc) =
366 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
367 state.cursor_row = lr;
368 state.cursor_col = lc;
369 }
370 } else if state.cursor_row + 1 < state.lines.len() {
371 state.cursor_row += 1;
372 state.cursor_col = state
373 .cursor_col
374 .min(state.lines[state.cursor_row].chars().count());
375 }
376 consumed_indices.push(i);
377 }
378 KeyCode::Home => {
379 state.cursor_col = 0;
380 consumed_indices.push(i);
381 }
382 KeyCode::Delete => {
383 let line_len = state.lines[state.cursor_row].chars().count();
384 if state.cursor_col < line_len {
385 let start = byte_index_for_char(
386 &state.lines[state.cursor_row],
387 state.cursor_col,
388 );
389 let end = byte_index_for_char(
390 &state.lines[state.cursor_row],
391 state.cursor_col + 1,
392 );
393 state.lines[state.cursor_row].replace_range(start..end, "");
394 } else if state.cursor_row + 1 < state.lines.len() {
395 let next = state.lines.remove(state.cursor_row + 1);
396 state.lines[state.cursor_row].push_str(&next);
397 }
398 consumed_indices.push(i);
399 }
400 KeyCode::End => {
401 state.cursor_col = state.lines[state.cursor_row].chars().count();
402 consumed_indices.push(i);
403 }
404 _ => {}
405 }
406 }
407 if let Event::Paste(ref text) = event {
408 for ch in text.chars() {
409 if ch == '\n' || ch == '\r' {
410 let split_index = byte_index_for_char(
411 &state.lines[state.cursor_row],
412 state.cursor_col,
413 );
414 let remainder = state.lines[state.cursor_row].split_off(split_index);
415 state.cursor_row += 1;
416 state.lines.insert(state.cursor_row, remainder);
417 state.cursor_col = 0;
418 } else {
419 if let Some(max) = state.max_length {
420 let total: usize =
421 state.lines.iter().map(|l| l.chars().count()).sum();
422 if total >= max {
423 break;
424 }
425 }
426 let index = byte_index_for_char(
427 &state.lines[state.cursor_row],
428 state.cursor_col,
429 );
430 state.lines[state.cursor_row].insert(index, ch);
431 state.cursor_col += 1;
432 }
433 }
434 consumed_indices.push(i);
435 }
436 }
437
438 for index in consumed_indices {
439 self.consumed[index] = true;
440 }
441 }
442
443 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
444 let (cursor_vrow, cursor_vcol) =
445 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
446
447 if cursor_vrow < state.scroll_offset {
448 state.scroll_offset = cursor_vrow;
449 }
450 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
451 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
452 }
453
454 self.interaction_count += 1;
455 self.commands.push(Command::BeginContainer {
456 direction: Direction::Column,
457 gap: 0,
458 align: Align::Start,
459 justify: Justify::Start,
460 border: None,
461 border_sides: BorderSides::all(),
462 border_style: Style::new().fg(self.theme.border),
463 bg_color: None,
464 padding: Padding::default(),
465 margin: Margin::default(),
466 constraints: Constraints::default(),
467 title: None,
468 grow: 0,
469 group_name: None,
470 });
471
472 for vi in 0..visible_rows as usize {
473 let actual_vi = state.scroll_offset + vi;
474 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
475 let line = &state.lines[vl.logical_row];
476 let text: String = line
477 .chars()
478 .skip(vl.char_start)
479 .take(vl.char_count)
480 .collect();
481 (text, actual_vi == cursor_vrow)
482 } else {
483 (String::new(), false)
484 };
485
486 let mut rendered = seg_text.clone();
487 let mut style = if seg_text.is_empty() {
488 Style::new().fg(self.theme.text_dim)
489 } else {
490 Style::new().fg(self.theme.text)
491 };
492
493 if is_cursor_line && focused {
494 rendered.clear();
495 for (idx, ch) in seg_text.chars().enumerate() {
496 if idx == cursor_vcol {
497 rendered.push('▎');
498 }
499 rendered.push(ch);
500 }
501 if cursor_vcol >= seg_text.chars().count() {
502 rendered.push('▎');
503 }
504 style = Style::new().fg(self.theme.text);
505 }
506
507 self.styled(rendered, style);
508 }
509 self.commands.push(Command::EndContainer);
510 self.last_text_idx = None;
511
512 self
513 }
514
515 pub fn progress(&mut self, ratio: f64) -> &mut Self {
520 self.progress_bar(ratio, 20)
521 }
522
523 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
528 let clamped = ratio.clamp(0.0, 1.0);
529 let filled = (clamped * width as f64).round() as u32;
530 let empty = width.saturating_sub(filled);
531 let mut bar = String::new();
532 for _ in 0..filled {
533 bar.push('█');
534 }
535 for _ in 0..empty {
536 bar.push('░');
537 }
538 self.text(bar)
539 }
540}