Skip to main content

slt/widgets/
input.rs

1/// Accumulated static output lines for [`crate::run_static`].
2///
3/// Use [`println`](Self::println) to append lines above the dynamic inline TUI.
4#[derive(Debug, Clone, Default)]
5pub struct StaticOutput {
6    lines: Vec<String>,
7    new_lines: Vec<String>,
8}
9
10impl StaticOutput {
11    /// Create an empty static output buffer.
12    pub fn new() -> Self {
13        Self::default()
14    }
15
16    /// Append one line of static output.
17    pub fn println(&mut self, line: impl Into<String>) {
18        let line = line.into();
19        self.lines.push(line.clone());
20        self.new_lines.push(line);
21    }
22
23    /// Return all accumulated static lines.
24    pub fn lines(&self) -> &[String] {
25        &self.lines
26    }
27
28    /// Drain and return only lines added since the previous drain.
29    pub fn drain_new(&mut self) -> Vec<String> {
30        std::mem::take(&mut self.new_lines)
31    }
32
33    /// Clear all accumulated lines.
34    pub fn clear(&mut self) {
35        self.lines.clear();
36        self.new_lines.clear();
37    }
38}
39
40/// State for a single-line text input widget.
41///
42/// Pass a mutable reference to `Context::text_input` each frame. The widget
43/// handles all keyboard events when focused.
44///
45/// # Example
46///
47/// ```no_run
48/// # use slt::widgets::TextInputState;
49/// # slt::run(|ui: &mut slt::Context| {
50/// let mut input = TextInputState::with_placeholder("Type here...");
51/// ui.text_input(&mut input);
52/// println!("{}", input.value);
53/// # });
54/// ```
55pub struct TextInputState {
56    /// The current input text.
57    pub value: String,
58    /// Cursor position as a character index into `value`.
59    pub cursor: usize,
60    /// Placeholder text shown when `value` is empty.
61    pub placeholder: String,
62    /// Maximum character count. Input is rejected beyond this limit.
63    pub max_length: Option<usize>,
64    /// The most recent validation error message, if any.
65    pub validation_error: Option<String>,
66    /// When `true`, input is displayed as `•` characters (for passwords).
67    pub masked: bool,
68    /// Autocomplete candidates shown below the input.
69    pub suggestions: Vec<String>,
70    /// Highlighted index within the currently shown suggestions.
71    pub suggestion_index: usize,
72    /// Whether the suggestions popup should be rendered.
73    pub show_suggestions: bool,
74    /// Multiple validators that produce their own error messages.
75    validators: Vec<TextInputValidator>,
76    /// All current validation errors from all validators.
77    validation_errors: Vec<String>,
78}
79
80impl std::fmt::Debug for TextInputState {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("TextInputState")
83            .field("value", &self.value)
84            .field("cursor", &self.cursor)
85            .field("placeholder", &self.placeholder)
86            .field("max_length", &self.max_length)
87            .field("validation_error", &self.validation_error)
88            .field("masked", &self.masked)
89            .field("suggestions", &self.suggestions)
90            .field("suggestion_index", &self.suggestion_index)
91            .field("show_suggestions", &self.show_suggestions)
92            .field("validators_len", &self.validators.len())
93            .field("validation_errors", &self.validation_errors)
94            .finish()
95    }
96}
97
98impl Clone for TextInputState {
99    /// # Clone behavior
100    ///
101    /// `validators` registered via [`TextInputState::add_validator`] are **not**
102    /// cloned because closures are not `Clone`. `validation_errors` is preserved
103    /// in the clone, but it becomes stale — calling
104    /// [`TextInputState::run_validators`] on the clone will clear errors without
105    /// re-running any validation.
106    ///
107    /// Re-register validators on the clone before calling `run_validators()`.
108    fn clone(&self) -> Self {
109        Self {
110            value: self.value.clone(),
111            cursor: self.cursor,
112            placeholder: self.placeholder.clone(),
113            max_length: self.max_length,
114            validation_error: self.validation_error.clone(),
115            masked: self.masked,
116            suggestions: self.suggestions.clone(),
117            suggestion_index: self.suggestion_index,
118            show_suggestions: self.show_suggestions,
119            validators: Vec::new(),
120            validation_errors: self.validation_errors.clone(),
121        }
122    }
123}
124
125impl TextInputState {
126    /// Create an empty text input state.
127    pub fn new() -> Self {
128        Self {
129            value: String::new(),
130            cursor: 0,
131            placeholder: String::new(),
132            max_length: None,
133            validation_error: None,
134            masked: false,
135            suggestions: Vec::new(),
136            suggestion_index: 0,
137            show_suggestions: false,
138            validators: Vec::new(),
139            validation_errors: Vec::new(),
140        }
141    }
142
143    /// Create a text input with placeholder text shown when the value is empty.
144    pub fn with_placeholder(p: impl Into<String>) -> Self {
145        Self {
146            placeholder: p.into(),
147            ..Self::new()
148        }
149    }
150
151    /// Set the maximum allowed character count.
152    pub fn max_length(mut self, len: usize) -> Self {
153        self.max_length = Some(len);
154        self
155    }
156
157    /// Validate the current value and store the latest error message.
158    ///
159    /// Sets [`TextInputState::validation_error`] to `None` when validation
160    /// succeeds, or to `Some(error)` when validation fails.
161    ///
162    /// This is a backward-compatible shorthand that runs a single validator.
163    /// For multiple validators, use [`add_validator`](Self::add_validator) and [`run_validators`](Self::run_validators).
164    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
165        self.validation_error = validator(&self.value).err();
166    }
167
168    /// Add a validator function that produces its own error message.
169    ///
170    /// Multiple validators can be added. Call [`run_validators`](Self::run_validators)
171    /// to execute all validators and collect their errors.
172    ///
173    /// # Note on cloning
174    ///
175    /// Validators are **not** preserved across [`Clone`] because closures are
176    /// not `Clone`. Re-register after cloning the state.
177    pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
178        self.validators.push(Box::new(f));
179    }
180
181    /// Run all registered validators and collect their error messages.
182    ///
183    /// Updates `validation_errors` with all errors from all validators.
184    /// Also updates `validation_error` to the first error for backward compatibility.
185    ///
186    /// # Note on cloning
187    ///
188    /// Validators do not survive [`Clone`]. Calling this on a cloned state with
189    /// no re-registered validators clears `validation_errors` without re-running
190    /// any check. Re-register validators on the clone first.
191    pub fn run_validators(&mut self) {
192        self.validation_errors.clear();
193        for validator in &self.validators {
194            if let Err(err) = validator(&self.value) {
195                self.validation_errors.push(err);
196            }
197        }
198        self.validation_error = self.validation_errors.first().cloned();
199    }
200
201    /// Get all current validation errors from all validators.
202    pub fn errors(&self) -> &[String] {
203        &self.validation_errors
204    }
205
206    /// Set autocomplete suggestions and reset popup state.
207    pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
208        self.suggestions = suggestions;
209        self.suggestion_index = 0;
210        self.show_suggestions = !self.suggestions.is_empty();
211    }
212
213    /// Return suggestions that start with the current input (case-insensitive).
214    pub fn matched_suggestions(&self) -> Vec<&str> {
215        if self.value.is_empty() {
216            return Vec::new();
217        }
218        let lower = self.value.to_lowercase();
219        self.suggestions
220            .iter()
221            .filter(|s| s.to_lowercase().starts_with(&lower))
222            .map(|s| s.as_str())
223            .collect()
224    }
225}
226
227impl Default for TextInputState {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233/// A single form field with label and validation.
234#[derive(Debug, Default)]
235pub struct FormField {
236    /// Field label shown above the input.
237    pub label: String,
238    /// Text input state for this field.
239    pub input: TextInputState,
240    /// Validation error shown below the input when present.
241    pub error: Option<String>,
242}
243
244impl FormField {
245    /// Create a new form field with the given label.
246    pub fn new(label: impl Into<String>) -> Self {
247        Self {
248            label: label.into(),
249            input: TextInputState::new(),
250            error: None,
251        }
252    }
253
254    /// Set placeholder text for this field's input.
255    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
256        self.input.placeholder = p.into();
257        self
258    }
259}
260
261/// State for a form with multiple fields.
262#[derive(Debug)]
263pub struct FormState {
264    /// Ordered list of form fields.
265    pub fields: Vec<FormField>,
266    /// Whether the form has been successfully submitted.
267    pub submitted: bool,
268}
269
270impl FormState {
271    /// Create an empty form state.
272    pub fn new() -> Self {
273        Self {
274            fields: Vec::new(),
275            submitted: false,
276        }
277    }
278
279    /// Add a field and return the updated form for chaining.
280    pub fn field(mut self, field: FormField) -> Self {
281        self.fields.push(field);
282        self
283    }
284
285    /// Validate all fields with the given validators.
286    ///
287    /// Returns `true` when all validations pass.
288    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
289        let mut all_valid = true;
290        for (i, field) in self.fields.iter_mut().enumerate() {
291            if let Some(validator) = validators.get(i) {
292                match validator(&field.input.value) {
293                    Ok(()) => field.error = None,
294                    Err(msg) => {
295                        field.error = Some(msg);
296                        all_valid = false;
297                    }
298                }
299            }
300        }
301        all_valid
302    }
303
304    /// Get field value by index.
305    pub fn value(&self, index: usize) -> &str {
306        self.fields
307            .get(index)
308            .map(|f| f.input.value.as_str())
309            .unwrap_or("")
310    }
311}
312
313impl Default for FormState {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319/// State for toast notification display.
320///
321/// Add messages with [`ToastState::info`], [`ToastState::success`],
322/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
323/// `Context::toast` each frame. Expired messages are removed automatically.
324#[derive(Debug, Clone)]
325pub struct ToastState {
326    /// Active toast messages, ordered oldest-first.
327    pub messages: Vec<ToastMessage>,
328}
329
330/// A single toast notification message.
331#[derive(Debug, Clone)]
332pub struct ToastMessage {
333    /// The text content of the notification.
334    pub text: String,
335    /// Severity level, used to choose the display color.
336    pub level: ToastLevel,
337    /// The tick at which this message was created.
338    pub created_tick: u64,
339    /// How many ticks the message remains visible.
340    pub duration_ticks: u64,
341}
342
343impl Default for ToastMessage {
344    fn default() -> Self {
345        Self {
346            text: String::new(),
347            level: ToastLevel::Info,
348            created_tick: 0,
349            duration_ticks: 30,
350        }
351    }
352}
353
354/// Severity level for a [`ToastMessage`].
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum ToastLevel {
357    /// Informational message (primary color).
358    Info,
359    /// Success message (success color).
360    Success,
361    /// Warning message (warning color).
362    Warning,
363    /// Error message (error color).
364    Error,
365}
366
367/// Severity level for alert widgets.
368#[non_exhaustive]
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum AlertLevel {
371    /// Informational alert.
372    Info,
373    /// Success alert.
374    Success,
375    /// Warning alert.
376    Warning,
377    /// Error alert.
378    Error,
379}
380
381impl ToastState {
382    /// Create an empty toast state with no messages.
383    pub fn new() -> Self {
384        Self {
385            messages: Vec::new(),
386        }
387    }
388
389    /// Push an informational toast visible for 30 ticks.
390    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
391        self.push(text, ToastLevel::Info, tick, 30);
392    }
393
394    /// Push a success toast visible for 30 ticks.
395    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
396        self.push(text, ToastLevel::Success, tick, 30);
397    }
398
399    /// Push a warning toast visible for 50 ticks.
400    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
401        self.push(text, ToastLevel::Warning, tick, 50);
402    }
403
404    /// Push an error toast visible for 80 ticks.
405    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
406        self.push(text, ToastLevel::Error, tick, 80);
407    }
408
409    /// Push a toast with a custom level and duration.
410    pub fn push(
411        &mut self,
412        text: impl Into<String>,
413        level: ToastLevel,
414        tick: u64,
415        duration_ticks: u64,
416    ) {
417        self.messages.push(ToastMessage {
418            text: text.into(),
419            level,
420            created_tick: tick,
421            duration_ticks,
422        });
423    }
424
425    /// Remove all messages whose display duration has elapsed.
426    ///
427    /// Called automatically by `Context::toast` before rendering.
428    pub fn cleanup(&mut self, current_tick: u64) {
429        self.messages.retain(|message| {
430            current_tick < message.created_tick.saturating_add(message.duration_ticks)
431        });
432    }
433}
434
435impl Default for ToastState {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441/// Default maximum number of [`TextareaSnapshot`] entries kept in
442/// [`TextareaState::history`]. Used by [`TextareaState::new`] and the
443/// `Default` impl. Override per-instance via
444/// [`TextareaState::history_max`].
445pub(crate) const DEFAULT_TEXTAREA_HISTORY_MAX: usize = 100;
446
447/// Snapshot of textarea content + cursor for the undo/redo history stack.
448///
449/// One snapshot is pushed before every destructive mutation (char insert,
450/// delete, Enter, Backspace, paste). `Ctrl+Z` walks the index backward to a
451/// previous snapshot; `Ctrl+Y` walks it forward.
452///
453/// Crate-internal — the `pub(crate)` visibility keeps the history layout an
454/// implementation detail. Inspect via the public undo/redo behavior instead.
455#[derive(Debug, Clone)]
456pub(crate) struct TextareaSnapshot {
457    /// Lines of text at the time of the snapshot.
458    pub(crate) lines: Vec<String>,
459    /// Cursor row at the time of the snapshot.
460    pub(crate) cursor_row: usize,
461    /// Cursor column at the time of the snapshot.
462    pub(crate) cursor_col: usize,
463}
464
465/// State for a multi-line text area widget.
466///
467/// Pass a mutable reference to `Context::textarea` each frame along with the
468/// number of visible rows. The widget handles all keyboard events when focused.
469///
470/// # Undo / redo
471///
472/// `Ctrl+Z` undoes the most recent edit and `Ctrl+Y` redoes it. The widget
473/// pushes a snapshot before every destructive mutation (char insert, delete,
474/// Enter, Backspace, paste). Rapid character typing coalesces into a single
475/// undoable batch — only the first char of a typing burst pushes a snapshot.
476/// History is capped at [`history_max`](Self::history_max) entries (default
477/// `100`); the oldest snapshot is dropped when the cap is exceeded.
478///
479/// # Example
480///
481/// ```no_run
482/// # use slt::widgets::TextareaState;
483/// # slt::run(|ui: &mut slt::Context| {
484/// let mut state = TextareaState::new();
485/// // Type, then press Ctrl+Z to undo or Ctrl+Y to redo.
486/// ui.textarea(&mut state, 5);
487/// # });
488/// ```
489#[derive(Debug, Clone)]
490pub struct TextareaState {
491    /// The lines of text, one entry per line.
492    pub lines: Vec<String>,
493    /// Row index of the cursor (0-based, logical line).
494    pub cursor_row: usize,
495    /// Column index of the cursor within the current row (character index).
496    pub cursor_col: usize,
497    /// Maximum total character count across all lines.
498    pub max_length: Option<usize>,
499    /// When set, lines longer than this display-column width are soft-wrapped.
500    pub wrap_width: Option<u32>,
501    /// First visible visual line (managed internally by `textarea()`).
502    pub scroll_offset: usize,
503    /// Undo/redo snapshot stack. Newest entry is at the tip; the index walks
504    /// backward on `Ctrl+Z` and forward on `Ctrl+Y`.
505    pub(crate) history: Vec<TextareaSnapshot>,
506    /// Pointer into [`history`](Self::history) for the next undo target.
507    pub(crate) history_index: usize,
508    /// Maximum [`history`](Self::history) length before the oldest snapshot is
509    /// evicted. Defaults to [`DEFAULT_TEXTAREA_HISTORY_MAX`].
510    pub(crate) history_max: usize,
511    /// Whether the previous keypress was a `Char` insert. Used to coalesce
512    /// rapid typing into a single undoable burst — when true, the next `Char`
513    /// keypress does not push a snapshot.
514    pub(crate) last_was_char_insert: bool,
515}
516
517impl TextareaState {
518    /// Create an empty text area state with one blank line.
519    pub fn new() -> Self {
520        Self {
521            lines: vec![String::new()],
522            cursor_row: 0,
523            cursor_col: 0,
524            max_length: None,
525            wrap_width: None,
526            scroll_offset: 0,
527            history: Vec::new(),
528            history_index: 0,
529            history_max: DEFAULT_TEXTAREA_HISTORY_MAX,
530            last_was_char_insert: false,
531        }
532    }
533
534    /// Return all lines joined with newline characters.
535    pub fn value(&self) -> String {
536        self.lines.join("\n")
537    }
538
539    /// Replace the content with the given text, splitting on newlines.
540    ///
541    /// Resets the cursor to the beginning of the first line and clears the
542    /// undo history — programmatic replacement is treated as a fresh state,
543    /// not an undoable edit.
544    pub fn set_value(&mut self, text: impl Into<String>) {
545        let value = text.into();
546        self.lines = value.split('\n').map(str::to_string).collect();
547        if self.lines.is_empty() {
548            self.lines.push(String::new());
549        }
550        self.cursor_row = 0;
551        self.cursor_col = 0;
552        self.scroll_offset = 0;
553        self.history.clear();
554        self.history_index = 0;
555        self.last_was_char_insert = false;
556    }
557
558    /// Set the maximum allowed total character count.
559    pub fn max_length(mut self, len: usize) -> Self {
560        self.max_length = Some(len);
561        self
562    }
563
564    /// Enable soft word-wrap at the given display-column width.
565    pub fn word_wrap(mut self, width: u32) -> Self {
566        self.wrap_width = Some(width);
567        self
568    }
569
570    /// Override the maximum number of undo snapshots kept (default `100`).
571    ///
572    /// When the history exceeds this cap the oldest snapshot is dropped.
573    /// Setting `0` disables undo recording — the field is read every keypress.
574    pub fn history_max(mut self, cap: usize) -> Self {
575        self.history_max = cap;
576        self
577    }
578
579    /// Number of undo snapshots currently retained.
580    ///
581    /// Read-only — useful for tests and debugging the history cap. The cap
582    /// itself is set via [`history_max`](Self::history_max).
583    pub fn history_len(&self) -> usize {
584        self.history.len()
585    }
586
587    /// Maximum number of undo snapshots retained.
588    ///
589    /// Mirrors [`history_max`](Self::history_max) (the builder setter) but as
590    /// a getter — useful for tests asserting the cap stays bounded.
591    pub fn history_cap(&self) -> usize {
592        self.history_max
593    }
594
595    /// Push a snapshot of the current content + cursor onto the undo stack.
596    ///
597    /// Truncates any redo tail beyond `history_index`, appends the snapshot,
598    /// and caps the stack at [`history_max`](Self::history_max) by dropping the
599    /// oldest entry. `history_index` is left pointing one past the newest
600    /// snapshot so the next `Ctrl+Z` returns to the just-pushed state.
601    pub(crate) fn push_history(&mut self) {
602        if self.history_max == 0 {
603            return;
604        }
605        // Drop any redo tail — a fresh edit invalidates the redo branch.
606        if self.history_index < self.history.len() {
607            self.history.truncate(self.history_index);
608        }
609        self.history.push(TextareaSnapshot {
610            lines: self.lines.clone(),
611            cursor_row: self.cursor_row,
612            cursor_col: self.cursor_col,
613        });
614        // Evict oldest when over the cap. `Vec::remove(0)` is O(n) but the
615        // history cap is small (default 100) and this only runs at the cap
616        // boundary, so the cost is bounded.
617        while self.history.len() > self.history_max {
618            self.history.remove(0);
619        }
620        self.history_index = self.history.len();
621    }
622
623    /// Walk the undo index back one step and apply the snapshot.
624    ///
625    /// No-op when the history is empty or already at the start. Returns `true`
626    /// when a snapshot was applied.
627    pub(crate) fn undo(&mut self) -> bool {
628        if self.history.is_empty() || self.history_index == 0 {
629            return false;
630        }
631        // First Ctrl+Z after edits captures the current (unsaved) tip so the
632        // user can redo back to it; subsequent presses walk down the stack.
633        if self.history_index == self.history.len() {
634            self.history.push(TextareaSnapshot {
635                lines: self.lines.clone(),
636                cursor_row: self.cursor_row,
637                cursor_col: self.cursor_col,
638            });
639        }
640        self.history_index -= 1;
641        let snap = &self.history[self.history_index];
642        self.lines = snap.lines.clone();
643        self.cursor_row = snap.cursor_row;
644        self.cursor_col = snap.cursor_col;
645        true
646    }
647
648    /// Walk the undo index forward one step and apply the snapshot.
649    ///
650    /// No-op when already at the redo tip. Returns `true` when a snapshot was
651    /// applied.
652    pub(crate) fn redo(&mut self) -> bool {
653        if self.history_index + 1 >= self.history.len() {
654            return false;
655        }
656        self.history_index += 1;
657        let snap = &self.history[self.history_index];
658        self.lines = snap.lines.clone();
659        self.cursor_row = snap.cursor_row;
660        self.cursor_col = snap.cursor_col;
661        true
662    }
663}
664
665impl Default for TextareaState {
666    fn default() -> Self {
667        Self::new()
668    }
669}
670
671/// State for an animated spinner widget.
672///
673/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
674/// `Context::spinner` each frame. The frame advances automatically with the
675/// tick counter.
676#[derive(Debug, Clone)]
677pub struct SpinnerState {
678    chars: &'static [char],
679}
680
681static DOTS_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
682static LINE_CHARS: &[char] = &['|', '/', '-', '\\'];
683
684impl SpinnerState {
685    /// Create a dots-style spinner using braille characters.
686    ///
687    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
688    pub fn dots() -> Self {
689        Self { chars: DOTS_CHARS }
690    }
691
692    /// Create a line-style spinner using ASCII characters.
693    ///
694    /// Cycles through: `| / - \`
695    pub fn line() -> Self {
696        Self { chars: LINE_CHARS }
697    }
698
699    /// Return the spinner character for the given tick.
700    pub fn frame(&self, tick: u64) -> char {
701        if self.chars.is_empty() {
702            return ' ';
703        }
704        self.chars[tick as usize % self.chars.len()]
705    }
706}
707
708impl Default for SpinnerState {
709    fn default() -> Self {
710        Self::dots()
711    }
712}