1use super::*;
2
3impl Context {
4 pub fn text_input(&mut self, state: &mut TextInputState) -> Response {
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 let old_value = state.value.clone();
26 state.cursor = state.cursor.min(state.value.chars().count());
27
28 if focused {
29 let mut consumed_indices = Vec::new();
30 for (i, event) in self.events.iter().enumerate() {
31 if let Event::Key(key) = event {
32 if key.kind != KeyEventKind::Press {
33 continue;
34 }
35 let matched_suggestions = if state.show_suggestions {
36 state
37 .matched_suggestions()
38 .into_iter()
39 .map(str::to_string)
40 .collect::<Vec<String>>()
41 } else {
42 Vec::new()
43 };
44 let suggestions_visible = !matched_suggestions.is_empty();
45 if suggestions_visible {
46 state.suggestion_index = state
47 .suggestion_index
48 .min(matched_suggestions.len().saturating_sub(1));
49 }
50 match key.code {
51 KeyCode::Up if suggestions_visible => {
52 state.suggestion_index = state.suggestion_index.saturating_sub(1);
53 consumed_indices.push(i);
54 }
55 KeyCode::Down if suggestions_visible => {
56 state.suggestion_index = (state.suggestion_index + 1)
57 .min(matched_suggestions.len().saturating_sub(1));
58 consumed_indices.push(i);
59 }
60 KeyCode::Esc if state.show_suggestions => {
61 state.show_suggestions = false;
62 state.suggestion_index = 0;
63 consumed_indices.push(i);
64 }
65 KeyCode::Tab if suggestions_visible => {
66 if let Some(selected) = matched_suggestions
67 .get(state.suggestion_index)
68 .or_else(|| matched_suggestions.first())
69 {
70 state.value = selected.clone();
71 state.cursor = state.value.chars().count();
72 state.show_suggestions = false;
73 state.suggestion_index = 0;
74 }
75 consumed_indices.push(i);
76 }
77 KeyCode::Char(ch) => {
78 if let Some(max) = state.max_length {
79 if state.value.chars().count() >= max {
80 continue;
81 }
82 }
83 let index = byte_index_for_char(&state.value, state.cursor);
84 state.value.insert(index, ch);
85 state.cursor += 1;
86 if !state.suggestions.is_empty() {
87 state.show_suggestions = true;
88 state.suggestion_index = 0;
89 }
90 consumed_indices.push(i);
91 }
92 KeyCode::Backspace => {
93 if state.cursor > 0 {
94 let start = byte_index_for_char(&state.value, state.cursor - 1);
95 let end = byte_index_for_char(&state.value, state.cursor);
96 state.value.replace_range(start..end, "");
97 state.cursor -= 1;
98 }
99 if !state.suggestions.is_empty() {
100 state.show_suggestions = true;
101 state.suggestion_index = 0;
102 }
103 consumed_indices.push(i);
104 }
105 KeyCode::Left => {
106 state.cursor = state.cursor.saturating_sub(1);
107 consumed_indices.push(i);
108 }
109 KeyCode::Right => {
110 state.cursor = (state.cursor + 1).min(state.value.chars().count());
111 consumed_indices.push(i);
112 }
113 KeyCode::Home => {
114 state.cursor = 0;
115 consumed_indices.push(i);
116 }
117 KeyCode::Delete => {
118 let len = state.value.chars().count();
119 if state.cursor < len {
120 let start = byte_index_for_char(&state.value, state.cursor);
121 let end = byte_index_for_char(&state.value, state.cursor + 1);
122 state.value.replace_range(start..end, "");
123 }
124 if !state.suggestions.is_empty() {
125 state.show_suggestions = true;
126 state.suggestion_index = 0;
127 }
128 consumed_indices.push(i);
129 }
130 KeyCode::End => {
131 state.cursor = state.value.chars().count();
132 consumed_indices.push(i);
133 }
134 _ => {}
135 }
136 }
137 if let Event::Paste(ref text) = event {
138 for ch in text.chars() {
139 if let Some(max) = state.max_length {
140 if state.value.chars().count() >= max {
141 break;
142 }
143 }
144 let index = byte_index_for_char(&state.value, state.cursor);
145 state.value.insert(index, ch);
146 state.cursor += 1;
147 }
148 if !state.suggestions.is_empty() {
149 state.show_suggestions = true;
150 state.suggestion_index = 0;
151 }
152 consumed_indices.push(i);
153 }
154 }
155
156 for index in consumed_indices {
157 self.consumed[index] = true;
158 }
159 }
160
161 if state.value.is_empty() {
162 state.show_suggestions = false;
163 state.suggestion_index = 0;
164 }
165
166 let matched_suggestions = if state.show_suggestions {
167 state
168 .matched_suggestions()
169 .into_iter()
170 .map(str::to_string)
171 .collect::<Vec<String>>()
172 } else {
173 Vec::new()
174 };
175 if !matched_suggestions.is_empty() {
176 state.suggestion_index = state
177 .suggestion_index
178 .min(matched_suggestions.len().saturating_sub(1));
179 }
180
181 let visible_width = self.area_width.saturating_sub(4) as usize;
182 let input_text = if state.value.is_empty() {
183 if state.placeholder.len() > 100 {
184 slt_warn(
185 "text_input placeholder is very long (>100 chars) — consider shortening it",
186 );
187 }
188 let mut ph = state.placeholder.clone();
189 if focused {
190 ph.insert(0, '▎');
191 }
192 ph
193 } else {
194 let chars: Vec<char> = state.value.chars().collect();
195 let display_chars: Vec<char> = if state.masked {
196 vec!['•'; chars.len()]
197 } else {
198 chars.clone()
199 };
200
201 let cursor_display_pos: usize = display_chars[..state.cursor.min(display_chars.len())]
202 .iter()
203 .map(|c| UnicodeWidthChar::width(*c).unwrap_or(1))
204 .sum();
205
206 let scroll_offset = if cursor_display_pos >= visible_width {
207 cursor_display_pos - visible_width + 1
208 } else {
209 0
210 };
211
212 let mut rendered = String::new();
213 let mut current_width: usize = 0;
214 for (idx, &ch) in display_chars.iter().enumerate() {
215 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
216 if current_width + cw <= scroll_offset {
217 current_width += cw;
218 continue;
219 }
220 if current_width - scroll_offset >= visible_width {
221 break;
222 }
223 if focused && idx == state.cursor {
224 rendered.push('▎');
225 }
226 rendered.push(ch);
227 current_width += cw;
228 }
229 if focused && state.cursor >= display_chars.len() {
230 rendered.push('▎');
231 }
232 rendered
233 };
234 let input_style = if state.value.is_empty() && !focused {
235 Style::new().dim().fg(self.theme.text_dim)
236 } else {
237 Style::new().fg(self.theme.text)
238 };
239
240 let border_color = if focused {
241 self.theme.primary
242 } else if state.validation_error.is_some() {
243 self.theme.error
244 } else {
245 self.theme.border
246 };
247
248 let mut response = self
249 .bordered(Border::Rounded)
250 .border_style(Style::new().fg(border_color))
251 .px(1)
252 .col(|ui| {
253 ui.styled(input_text, input_style);
254 });
255 response.focused = focused;
256 response.changed = state.value != old_value;
257
258 let errors = state.errors();
259 if !errors.is_empty() {
260 for error in errors {
261 self.styled(
262 format!("⚠ {error}"),
263 Style::new().dim().fg(self.theme.error),
264 );
265 }
266 } else if let Some(error) = state.validation_error.clone() {
267 self.styled(
268 format!("⚠ {error}"),
269 Style::new().dim().fg(self.theme.error),
270 );
271 }
272
273 if state.show_suggestions && !matched_suggestions.is_empty() {
274 let start = state.suggestion_index.saturating_sub(4);
275 let end = (start + 5).min(matched_suggestions.len());
276 let suggestion_border = self.theme.border;
277 self.bordered(Border::Rounded)
278 .border_style(Style::new().fg(suggestion_border))
279 .px(1)
280 .col(|ui| {
281 for (idx, suggestion) in matched_suggestions[start..end].iter().enumerate() {
282 let actual_idx = start + idx;
283 if actual_idx == state.suggestion_index {
284 ui.styled(
285 suggestion.clone(),
286 Style::new()
287 .bg(ui.theme().selected_bg)
288 .fg(ui.theme().selected_fg),
289 );
290 } else {
291 ui.styled(suggestion.clone(), Style::new().fg(ui.theme().text));
292 }
293 }
294 });
295 }
296 response
297 }
298
299 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
305 self.styled(
306 state.frame(self.tick).to_string(),
307 Style::new().fg(self.theme.primary),
308 )
309 }
310
311 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
316 state.cleanup(self.tick);
317 if state.messages.is_empty() {
318 return self;
319 }
320
321 self.interaction_count += 1;
322 self.commands.push(Command::BeginContainer {
323 direction: Direction::Column,
324 gap: 0,
325 align: Align::Start,
326 justify: Justify::Start,
327 border: None,
328 border_sides: BorderSides::all(),
329 border_style: Style::new().fg(self.theme.border),
330 bg_color: None,
331 padding: Padding::default(),
332 margin: Margin::default(),
333 constraints: Constraints::default(),
334 title: None,
335 grow: 0,
336 group_name: None,
337 });
338 for message in state.messages.iter().rev() {
339 let color = match message.level {
340 ToastLevel::Info => self.theme.primary,
341 ToastLevel::Success => self.theme.success,
342 ToastLevel::Warning => self.theme.warning,
343 ToastLevel::Error => self.theme.error,
344 };
345 self.styled(format!(" ● {}", message.text), Style::new().fg(color));
346 }
347 self.commands.push(Command::EndContainer);
348 self.last_text_idx = None;
349
350 self
351 }
352
353 pub fn slider(
365 &mut self,
366 label: &str,
367 value: &mut f64,
368 range: std::ops::RangeInclusive<f64>,
369 ) -> Response {
370 let focused = self.register_focusable();
371 let mut changed = false;
372
373 let start = *range.start();
374 let end = *range.end();
375 let span = (end - start).max(0.0);
376 let step = if span > 0.0 { span / 20.0 } else { 0.0 };
377
378 *value = (*value).clamp(start, end);
379
380 if focused {
381 let mut consumed_indices = Vec::new();
382 for (i, event) in self.events.iter().enumerate() {
383 if let Event::Key(key) = event {
384 if key.kind != KeyEventKind::Press {
385 continue;
386 }
387
388 match key.code {
389 KeyCode::Left | KeyCode::Char('h') => {
390 if step > 0.0 {
391 let next = (*value - step).max(start);
392 if (next - *value).abs() > f64::EPSILON {
393 *value = next;
394 changed = true;
395 }
396 }
397 consumed_indices.push(i);
398 }
399 KeyCode::Right | KeyCode::Char('l') => {
400 if step > 0.0 {
401 let next = (*value + step).min(end);
402 if (next - *value).abs() > f64::EPSILON {
403 *value = next;
404 changed = true;
405 }
406 }
407 consumed_indices.push(i);
408 }
409 _ => {}
410 }
411 }
412 }
413
414 for idx in consumed_indices {
415 self.consumed[idx] = true;
416 }
417 }
418
419 let ratio = if span <= f64::EPSILON {
420 0.0
421 } else {
422 ((*value - start) / span).clamp(0.0, 1.0)
423 };
424
425 let value_text = format_compact_number(*value);
426 let label_width = UnicodeWidthStr::width(label) as u32;
427 let value_width = UnicodeWidthStr::width(value_text.as_str()) as u32;
428 let track_width = self
429 .area_width
430 .saturating_sub(label_width + value_width + 8)
431 .max(10) as usize;
432 let thumb_idx = if track_width <= 1 {
433 0
434 } else {
435 (ratio * (track_width as f64 - 1.0)).round() as usize
436 };
437
438 let mut track = String::with_capacity(track_width);
439 for i in 0..track_width {
440 if i == thumb_idx {
441 track.push('○');
442 } else if i < thumb_idx {
443 track.push('█');
444 } else {
445 track.push('━');
446 }
447 }
448
449 let text_color = self.theme.text;
450 let border_color = self.theme.border;
451 let primary_color = self.theme.primary;
452 let dim_color = self.theme.text_dim;
453 let mut response = self.container().row(|ui| {
454 ui.text(label).fg(text_color);
455 ui.text("[").fg(border_color);
456 ui.text(track).grow(1).fg(primary_color);
457 ui.text("]").fg(border_color);
458 if focused {
459 ui.text(value_text.as_str()).bold().fg(primary_color);
460 } else {
461 ui.text(value_text.as_str()).fg(dim_color);
462 }
463 });
464 response.focused = focused;
465 response.changed = changed;
466 response
467 }
468
469 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> Response {
477 if state.lines.is_empty() {
478 state.lines.push(String::new());
479 }
480 let old_lines = state.lines.clone();
481 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
482 state.cursor_col = state
483 .cursor_col
484 .min(state.lines[state.cursor_row].chars().count());
485
486 let focused = self.register_focusable();
487 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
488 let wrapping = state.wrap_width.is_some();
489
490 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
491
492 if focused {
493 let mut consumed_indices = Vec::new();
494 for (i, event) in self.events.iter().enumerate() {
495 if let Event::Key(key) = event {
496 if key.kind != KeyEventKind::Press {
497 continue;
498 }
499 match key.code {
500 KeyCode::Char(ch) => {
501 if let Some(max) = state.max_length {
502 let total: usize =
503 state.lines.iter().map(|line| line.chars().count()).sum();
504 if total >= max {
505 continue;
506 }
507 }
508 let index = byte_index_for_char(
509 &state.lines[state.cursor_row],
510 state.cursor_col,
511 );
512 state.lines[state.cursor_row].insert(index, ch);
513 state.cursor_col += 1;
514 consumed_indices.push(i);
515 }
516 KeyCode::Enter => {
517 let split_index = byte_index_for_char(
518 &state.lines[state.cursor_row],
519 state.cursor_col,
520 );
521 let remainder = state.lines[state.cursor_row].split_off(split_index);
522 state.cursor_row += 1;
523 state.lines.insert(state.cursor_row, remainder);
524 state.cursor_col = 0;
525 consumed_indices.push(i);
526 }
527 KeyCode::Backspace => {
528 if state.cursor_col > 0 {
529 let start = byte_index_for_char(
530 &state.lines[state.cursor_row],
531 state.cursor_col - 1,
532 );
533 let end = byte_index_for_char(
534 &state.lines[state.cursor_row],
535 state.cursor_col,
536 );
537 state.lines[state.cursor_row].replace_range(start..end, "");
538 state.cursor_col -= 1;
539 } else if state.cursor_row > 0 {
540 let current = state.lines.remove(state.cursor_row);
541 state.cursor_row -= 1;
542 state.cursor_col = state.lines[state.cursor_row].chars().count();
543 state.lines[state.cursor_row].push_str(¤t);
544 }
545 consumed_indices.push(i);
546 }
547 KeyCode::Left => {
548 if state.cursor_col > 0 {
549 state.cursor_col -= 1;
550 } else if state.cursor_row > 0 {
551 state.cursor_row -= 1;
552 state.cursor_col = state.lines[state.cursor_row].chars().count();
553 }
554 consumed_indices.push(i);
555 }
556 KeyCode::Right => {
557 let line_len = state.lines[state.cursor_row].chars().count();
558 if state.cursor_col < line_len {
559 state.cursor_col += 1;
560 } else if state.cursor_row + 1 < state.lines.len() {
561 state.cursor_row += 1;
562 state.cursor_col = 0;
563 }
564 consumed_indices.push(i);
565 }
566 KeyCode::Up => {
567 if wrapping {
568 let (vrow, vcol) = textarea_logical_to_visual(
569 &pre_vlines,
570 state.cursor_row,
571 state.cursor_col,
572 );
573 if vrow > 0 {
574 let (lr, lc) =
575 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
576 state.cursor_row = lr;
577 state.cursor_col = lc;
578 }
579 } else if state.cursor_row > 0 {
580 state.cursor_row -= 1;
581 state.cursor_col = state
582 .cursor_col
583 .min(state.lines[state.cursor_row].chars().count());
584 }
585 consumed_indices.push(i);
586 }
587 KeyCode::Down => {
588 if wrapping {
589 let (vrow, vcol) = textarea_logical_to_visual(
590 &pre_vlines,
591 state.cursor_row,
592 state.cursor_col,
593 );
594 if vrow + 1 < pre_vlines.len() {
595 let (lr, lc) =
596 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
597 state.cursor_row = lr;
598 state.cursor_col = lc;
599 }
600 } else if state.cursor_row + 1 < state.lines.len() {
601 state.cursor_row += 1;
602 state.cursor_col = state
603 .cursor_col
604 .min(state.lines[state.cursor_row].chars().count());
605 }
606 consumed_indices.push(i);
607 }
608 KeyCode::Home => {
609 state.cursor_col = 0;
610 consumed_indices.push(i);
611 }
612 KeyCode::Delete => {
613 let line_len = state.lines[state.cursor_row].chars().count();
614 if state.cursor_col < line_len {
615 let start = byte_index_for_char(
616 &state.lines[state.cursor_row],
617 state.cursor_col,
618 );
619 let end = byte_index_for_char(
620 &state.lines[state.cursor_row],
621 state.cursor_col + 1,
622 );
623 state.lines[state.cursor_row].replace_range(start..end, "");
624 } else if state.cursor_row + 1 < state.lines.len() {
625 let next = state.lines.remove(state.cursor_row + 1);
626 state.lines[state.cursor_row].push_str(&next);
627 }
628 consumed_indices.push(i);
629 }
630 KeyCode::End => {
631 state.cursor_col = state.lines[state.cursor_row].chars().count();
632 consumed_indices.push(i);
633 }
634 _ => {}
635 }
636 }
637 if let Event::Paste(ref text) = event {
638 for ch in text.chars() {
639 if ch == '\n' || ch == '\r' {
640 let split_index = byte_index_for_char(
641 &state.lines[state.cursor_row],
642 state.cursor_col,
643 );
644 let remainder = state.lines[state.cursor_row].split_off(split_index);
645 state.cursor_row += 1;
646 state.lines.insert(state.cursor_row, remainder);
647 state.cursor_col = 0;
648 } else {
649 if let Some(max) = state.max_length {
650 let total: usize =
651 state.lines.iter().map(|l| l.chars().count()).sum();
652 if total >= max {
653 break;
654 }
655 }
656 let index = byte_index_for_char(
657 &state.lines[state.cursor_row],
658 state.cursor_col,
659 );
660 state.lines[state.cursor_row].insert(index, ch);
661 state.cursor_col += 1;
662 }
663 }
664 consumed_indices.push(i);
665 }
666 }
667
668 for index in consumed_indices {
669 self.consumed[index] = true;
670 }
671 }
672
673 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
674 let (cursor_vrow, cursor_vcol) =
675 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
676
677 if cursor_vrow < state.scroll_offset {
678 state.scroll_offset = cursor_vrow;
679 }
680 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
681 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
682 }
683
684 let interaction_id = self.interaction_count;
685 self.interaction_count += 1;
686 let mut response = self.response_for(interaction_id);
687 response.focused = focused;
688 self.commands.push(Command::BeginContainer {
689 direction: Direction::Column,
690 gap: 0,
691 align: Align::Start,
692 justify: Justify::Start,
693 border: None,
694 border_sides: BorderSides::all(),
695 border_style: Style::new().fg(self.theme.border),
696 bg_color: None,
697 padding: Padding::default(),
698 margin: Margin::default(),
699 constraints: Constraints::default(),
700 title: None,
701 grow: 0,
702 group_name: None,
703 });
704
705 for vi in 0..visible_rows as usize {
706 let actual_vi = state.scroll_offset + vi;
707 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
708 let line = &state.lines[vl.logical_row];
709 let text: String = line
710 .chars()
711 .skip(vl.char_start)
712 .take(vl.char_count)
713 .collect();
714 (text, actual_vi == cursor_vrow)
715 } else {
716 (String::new(), false)
717 };
718
719 let mut rendered = seg_text.clone();
720 let mut style = if seg_text.is_empty() {
721 Style::new().fg(self.theme.text_dim)
722 } else {
723 Style::new().fg(self.theme.text)
724 };
725
726 if is_cursor_line && focused {
727 rendered.clear();
728 for (idx, ch) in seg_text.chars().enumerate() {
729 if idx == cursor_vcol {
730 rendered.push('▎');
731 }
732 rendered.push(ch);
733 }
734 if cursor_vcol >= seg_text.chars().count() {
735 rendered.push('▎');
736 }
737 style = Style::new().fg(self.theme.text);
738 }
739
740 self.styled(rendered, style);
741 }
742 self.commands.push(Command::EndContainer);
743 self.last_text_idx = None;
744
745 response.changed = state.lines != old_lines;
746 response
747 }
748
749 pub fn progress(&mut self, ratio: f64) -> &mut Self {
754 self.progress_bar(ratio, 20)
755 }
756
757 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
762 let clamped = ratio.clamp(0.0, 1.0);
763 let filled = (clamped * width as f64).round() as u32;
764 let empty = width.saturating_sub(filled);
765 let mut bar = String::new();
766 for _ in 0..filled {
767 bar.push('█');
768 }
769 for _ in 0..empty {
770 bar.push('░');
771 }
772 self.text(bar)
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::{EventBuilder, KeyCode, TestBackend};
780
781 #[test]
782 fn text_input_shows_matched_suggestions_for_prefix() {
783 let mut backend = TestBackend::new(40, 10);
784 let mut input = TextInputState::new();
785 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
786
787 let events = EventBuilder::new().key('h').key('e').key('l').build();
788 backend.run_with_events(events, |ui| {
789 ui.text_input(&mut input);
790 });
791
792 backend.assert_contains("hello");
793 backend.assert_contains("help");
794 assert!(!backend.to_string_trimmed().contains("world"));
795 assert_eq!(input.matched_suggestions().len(), 2);
796 }
797
798 #[test]
799 fn text_input_tab_accepts_top_suggestion() {
800 let mut backend = TestBackend::new(40, 10);
801 let mut input = TextInputState::new();
802 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
803
804 let events = EventBuilder::new()
805 .key('h')
806 .key('e')
807 .key('l')
808 .key_code(KeyCode::Tab)
809 .build();
810 backend.run_with_events(events, |ui| {
811 ui.text_input(&mut input);
812 });
813
814 assert_eq!(input.value, "hello");
815 assert!(!input.show_suggestions);
816 }
817
818 #[test]
819 fn text_input_empty_value_shows_no_suggestions() {
820 let mut backend = TestBackend::new(40, 10);
821 let mut input = TextInputState::new();
822 input.set_suggestions(vec!["hello".into(), "help".into(), "world".into()]);
823
824 backend.render(|ui| {
825 ui.text_input(&mut input);
826 });
827
828 let rendered = backend.to_string_trimmed();
829 assert!(!rendered.contains("hello"));
830 assert!(!rendered.contains("help"));
831 assert!(!rendered.contains("world"));
832 assert!(input.matched_suggestions().is_empty());
833 assert!(!input.show_suggestions);
834 }
835}