Skip to main content

slt/context/
widgets_input.rs

1use super::*;
2
3impl Context {
4    /// Render a single-line text input. Auto-handles cursor, typing, and backspace.
5    ///
6    /// The widget claims focus via [`Context::register_focusable`]. When focused,
7    /// it consumes character, backspace, arrow, Home, and End key events.
8    ///
9    /// # Example
10    ///
11    /// ```no_run
12    /// # use slt::widgets::TextInputState;
13    /// # slt::run(|ui: &mut slt::Context| {
14    /// let mut input = TextInputState::with_placeholder("Search...");
15    /// ui.text_input(&mut input);
16    /// // input.value holds the current text
17    /// # });
18    /// ```
19    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    /// Render an animated spinner.
187    ///
188    /// The spinner advances one frame per tick. Use [`SpinnerState::dots`] or
189    /// [`SpinnerState::line`] to create the state, then chain style methods to
190    /// color it.
191    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    /// Render toast notifications. Calls `state.cleanup(tick)` automatically.
199    ///
200    /// Expired messages are removed before rendering. If there are no active
201    /// messages, nothing is rendered and `self` is returned unchanged.
202    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    /// Render a multi-line text area with the given number of visible rows.
241    ///
242    /// When focused, handles character input, Enter (new line), Backspace,
243    /// arrow keys, Home, and End. The cursor is rendered as a block character.
244    ///
245    /// Set [`TextareaState::word_wrap`] to enable soft-wrapping at a given
246    /// display-column width. Up/Down then navigate visual lines.
247    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(&current);
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    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
516    ///
517    /// Uses block characters (`█` filled, `░` empty). For a custom width use
518    /// [`Context::progress_bar`].
519    pub fn progress(&mut self, ratio: f64) -> &mut Self {
520        self.progress_bar(ratio, 20)
521    }
522
523    /// Render a progress bar with a custom character width.
524    ///
525    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
526    /// characters rendered.
527    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}