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    fn clone(&self) -> Self {
100        Self {
101            value: self.value.clone(),
102            cursor: self.cursor,
103            placeholder: self.placeholder.clone(),
104            max_length: self.max_length,
105            validation_error: self.validation_error.clone(),
106            masked: self.masked,
107            suggestions: self.suggestions.clone(),
108            suggestion_index: self.suggestion_index,
109            show_suggestions: self.show_suggestions,
110            validators: Vec::new(),
111            validation_errors: self.validation_errors.clone(),
112        }
113    }
114}
115
116impl TextInputState {
117    /// Create an empty text input state.
118    pub fn new() -> Self {
119        Self {
120            value: String::new(),
121            cursor: 0,
122            placeholder: String::new(),
123            max_length: None,
124            validation_error: None,
125            masked: false,
126            suggestions: Vec::new(),
127            suggestion_index: 0,
128            show_suggestions: false,
129            validators: Vec::new(),
130            validation_errors: Vec::new(),
131        }
132    }
133
134    /// Create a text input with placeholder text shown when the value is empty.
135    pub fn with_placeholder(p: impl Into<String>) -> Self {
136        Self {
137            placeholder: p.into(),
138            ..Self::new()
139        }
140    }
141
142    /// Set the maximum allowed character count.
143    pub fn max_length(mut self, len: usize) -> Self {
144        self.max_length = Some(len);
145        self
146    }
147
148    /// Validate the current value and store the latest error message.
149    ///
150    /// Sets [`TextInputState::validation_error`] to `None` when validation
151    /// succeeds, or to `Some(error)` when validation fails.
152    ///
153    /// This is a backward-compatible shorthand that runs a single validator.
154    /// For multiple validators, use [`add_validator`](Self::add_validator) and [`run_validators`](Self::run_validators).
155    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
156        self.validation_error = validator(&self.value).err();
157    }
158
159    /// Add a validator function that produces its own error message.
160    ///
161    /// Multiple validators can be added. Call [`run_validators`](Self::run_validators)
162    /// to execute all validators and collect their errors.
163    pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
164        self.validators.push(Box::new(f));
165    }
166
167    /// Run all registered validators and collect their error messages.
168    ///
169    /// Updates `validation_errors` with all errors from all validators.
170    /// Also updates `validation_error` to the first error for backward compatibility.
171    pub fn run_validators(&mut self) {
172        self.validation_errors.clear();
173        for validator in &self.validators {
174            if let Err(err) = validator(&self.value) {
175                self.validation_errors.push(err);
176            }
177        }
178        self.validation_error = self.validation_errors.first().cloned();
179    }
180
181    /// Get all current validation errors from all validators.
182    pub fn errors(&self) -> &[String] {
183        &self.validation_errors
184    }
185
186    /// Set autocomplete suggestions and reset popup state.
187    pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
188        self.suggestions = suggestions;
189        self.suggestion_index = 0;
190        self.show_suggestions = !self.suggestions.is_empty();
191    }
192
193    /// Return suggestions that start with the current input (case-insensitive).
194    pub fn matched_suggestions(&self) -> Vec<&str> {
195        if self.value.is_empty() {
196            return Vec::new();
197        }
198        let lower = self.value.to_lowercase();
199        self.suggestions
200            .iter()
201            .filter(|s| s.to_lowercase().starts_with(&lower))
202            .map(|s| s.as_str())
203            .collect()
204    }
205}
206
207impl Default for TextInputState {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213/// A single form field with label and validation.
214#[derive(Debug, Default)]
215pub struct FormField {
216    /// Field label shown above the input.
217    pub label: String,
218    /// Text input state for this field.
219    pub input: TextInputState,
220    /// Validation error shown below the input when present.
221    pub error: Option<String>,
222}
223
224impl FormField {
225    /// Create a new form field with the given label.
226    pub fn new(label: impl Into<String>) -> Self {
227        Self {
228            label: label.into(),
229            input: TextInputState::new(),
230            error: None,
231        }
232    }
233
234    /// Set placeholder text for this field's input.
235    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
236        self.input.placeholder = p.into();
237        self
238    }
239}
240
241/// State for a form with multiple fields.
242#[derive(Debug)]
243pub struct FormState {
244    /// Ordered list of form fields.
245    pub fields: Vec<FormField>,
246    /// Whether the form has been successfully submitted.
247    pub submitted: bool,
248}
249
250impl FormState {
251    /// Create an empty form state.
252    pub fn new() -> Self {
253        Self {
254            fields: Vec::new(),
255            submitted: false,
256        }
257    }
258
259    /// Add a field and return the updated form for chaining.
260    pub fn field(mut self, field: FormField) -> Self {
261        self.fields.push(field);
262        self
263    }
264
265    /// Validate all fields with the given validators.
266    ///
267    /// Returns `true` when all validations pass.
268    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
269        let mut all_valid = true;
270        for (i, field) in self.fields.iter_mut().enumerate() {
271            if let Some(validator) = validators.get(i) {
272                match validator(&field.input.value) {
273                    Ok(()) => field.error = None,
274                    Err(msg) => {
275                        field.error = Some(msg);
276                        all_valid = false;
277                    }
278                }
279            }
280        }
281        all_valid
282    }
283
284    /// Get field value by index.
285    pub fn value(&self, index: usize) -> &str {
286        self.fields
287            .get(index)
288            .map(|f| f.input.value.as_str())
289            .unwrap_or("")
290    }
291}
292
293impl Default for FormState {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// State for toast notification display.
300///
301/// Add messages with [`ToastState::info`], [`ToastState::success`],
302/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
303/// `Context::toast` each frame. Expired messages are removed automatically.
304#[derive(Debug, Clone)]
305pub struct ToastState {
306    /// Active toast messages, ordered oldest-first.
307    pub messages: Vec<ToastMessage>,
308}
309
310/// A single toast notification message.
311#[derive(Debug, Clone)]
312pub struct ToastMessage {
313    /// The text content of the notification.
314    pub text: String,
315    /// Severity level, used to choose the display color.
316    pub level: ToastLevel,
317    /// The tick at which this message was created.
318    pub created_tick: u64,
319    /// How many ticks the message remains visible.
320    pub duration_ticks: u64,
321}
322
323impl Default for ToastMessage {
324    fn default() -> Self {
325        Self {
326            text: String::new(),
327            level: ToastLevel::Info,
328            created_tick: 0,
329            duration_ticks: 30,
330        }
331    }
332}
333
334/// Severity level for a [`ToastMessage`].
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum ToastLevel {
337    /// Informational message (primary color).
338    Info,
339    /// Success message (success color).
340    Success,
341    /// Warning message (warning color).
342    Warning,
343    /// Error message (error color).
344    Error,
345}
346
347/// Severity level for alert widgets.
348#[non_exhaustive]
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub enum AlertLevel {
351    /// Informational alert.
352    Info,
353    /// Success alert.
354    Success,
355    /// Warning alert.
356    Warning,
357    /// Error alert.
358    Error,
359}
360
361impl ToastState {
362    /// Create an empty toast state with no messages.
363    pub fn new() -> Self {
364        Self {
365            messages: Vec::new(),
366        }
367    }
368
369    /// Push an informational toast visible for 30 ticks.
370    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
371        self.push(text, ToastLevel::Info, tick, 30);
372    }
373
374    /// Push a success toast visible for 30 ticks.
375    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
376        self.push(text, ToastLevel::Success, tick, 30);
377    }
378
379    /// Push a warning toast visible for 50 ticks.
380    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
381        self.push(text, ToastLevel::Warning, tick, 50);
382    }
383
384    /// Push an error toast visible for 80 ticks.
385    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
386        self.push(text, ToastLevel::Error, tick, 80);
387    }
388
389    /// Push a toast with a custom level and duration.
390    pub fn push(
391        &mut self,
392        text: impl Into<String>,
393        level: ToastLevel,
394        tick: u64,
395        duration_ticks: u64,
396    ) {
397        self.messages.push(ToastMessage {
398            text: text.into(),
399            level,
400            created_tick: tick,
401            duration_ticks,
402        });
403    }
404
405    /// Remove all messages whose display duration has elapsed.
406    ///
407    /// Called automatically by `Context::toast` before rendering.
408    pub fn cleanup(&mut self, current_tick: u64) {
409        self.messages.retain(|message| {
410            current_tick < message.created_tick.saturating_add(message.duration_ticks)
411        });
412    }
413}
414
415impl Default for ToastState {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421/// State for a multi-line text area widget.
422///
423/// Pass a mutable reference to `Context::textarea` each frame along with the
424/// number of visible rows. The widget handles all keyboard events when focused.
425#[derive(Debug, Clone)]
426pub struct TextareaState {
427    /// The lines of text, one entry per line.
428    pub lines: Vec<String>,
429    /// Row index of the cursor (0-based, logical line).
430    pub cursor_row: usize,
431    /// Column index of the cursor within the current row (character index).
432    pub cursor_col: usize,
433    /// Maximum total character count across all lines.
434    pub max_length: Option<usize>,
435    /// When set, lines longer than this display-column width are soft-wrapped.
436    pub wrap_width: Option<u32>,
437    /// First visible visual line (managed internally by `textarea()`).
438    pub scroll_offset: usize,
439}
440
441impl TextareaState {
442    /// Create an empty text area state with one blank line.
443    pub fn new() -> Self {
444        Self {
445            lines: vec![String::new()],
446            cursor_row: 0,
447            cursor_col: 0,
448            max_length: None,
449            wrap_width: None,
450            scroll_offset: 0,
451        }
452    }
453
454    /// Return all lines joined with newline characters.
455    pub fn value(&self) -> String {
456        self.lines.join("\n")
457    }
458
459    /// Replace the content with the given text, splitting on newlines.
460    ///
461    /// Resets the cursor to the beginning of the first line.
462    pub fn set_value(&mut self, text: impl Into<String>) {
463        let value = text.into();
464        self.lines = value.split('\n').map(str::to_string).collect();
465        if self.lines.is_empty() {
466            self.lines.push(String::new());
467        }
468        self.cursor_row = 0;
469        self.cursor_col = 0;
470        self.scroll_offset = 0;
471    }
472
473    /// Set the maximum allowed total character count.
474    pub fn max_length(mut self, len: usize) -> Self {
475        self.max_length = Some(len);
476        self
477    }
478
479    /// Enable soft word-wrap at the given display-column width.
480    pub fn word_wrap(mut self, width: u32) -> Self {
481        self.wrap_width = Some(width);
482        self
483    }
484}
485
486impl Default for TextareaState {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492/// State for an animated spinner widget.
493///
494/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
495/// `Context::spinner` each frame. The frame advances automatically with the
496/// tick counter.
497#[derive(Debug, Clone)]
498pub struct SpinnerState {
499    chars: Vec<char>,
500}
501
502impl SpinnerState {
503    /// Create a dots-style spinner using braille characters.
504    ///
505    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
506    pub fn dots() -> Self {
507        Self {
508            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
509        }
510    }
511
512    /// Create a line-style spinner using ASCII characters.
513    ///
514    /// Cycles through: `| / - \`
515    pub fn line() -> Self {
516        Self {
517            chars: vec!['|', '/', '-', '\\'],
518        }
519    }
520
521    /// Return the spinner character for the given tick.
522    pub fn frame(&self, tick: u64) -> char {
523        if self.chars.is_empty() {
524            return ' ';
525        }
526        self.chars[tick as usize % self.chars.len()]
527    }
528}
529
530impl Default for SpinnerState {
531    fn default() -> Self {
532        Self::dots()
533    }
534}