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}