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 boxed, state-capturing field validator.
234///
235/// Unlike the deprecated [`FormValidator`] function pointer, a `Validator`
236/// wraps a closure, so it can capture surrounding state — a compiled matcher,
237/// a min/max pulled from config, or a sibling field's value. Built-in
238/// constructors live in the [`validators`](crate::widgets::validators) module.
239///
240/// You rarely construct one directly: [`FormField::validate`] accepts a closure
241/// and boxes it for you. Use [`Validator::new`] when you need to build a
242/// `Validator` value yourself.
243///
244/// # Example
245///
246/// ```no_run
247/// # use slt::widgets::Validator;
248/// let min = 3usize; // captured state — impossible with a fn pointer
249/// let v = Validator::new(move |s: &str| {
250///     if s.len() >= min { Ok(()) } else { Err(format!("min {min} chars")) }
251/// });
252/// assert!(v.run("hello").is_ok());
253/// assert!(v.run("hi").is_err());
254/// ```
255pub struct Validator(TextInputValidator);
256
257impl Validator {
258    /// Wrap a closure as a [`Validator`].
259    ///
260    /// The closure may capture state (it is `Box<dyn Fn>`, not a function
261    /// pointer).
262    pub fn new(f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
263        Self(Box::new(f))
264    }
265
266    /// Run the validator against `value`, returning its `Err` message on
267    /// failure.
268    pub fn run(&self, value: &str) -> Result<(), String> {
269        (self.0)(value)
270    }
271}
272
273impl std::fmt::Debug for Validator {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.write_str("Validator(<fn>)")
276    }
277}
278
279/// One in-flight asynchronous field validation.
280///
281/// Created by [`FormField::validate_async`] and polled each frame by
282/// [`Context::form_field`](crate::Context::form_field) (or directly via
283/// [`FormField::poll_async`]). Gated behind the `async` feature.
284#[cfg(feature = "async")]
285pub struct AsyncValidation {
286    rx: tokio::sync::oneshot::Receiver<Result<(), String>>,
287}
288
289#[cfg(feature = "async")]
290impl std::fmt::Debug for AsyncValidation {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.write_str("AsyncValidation(<pending>)")
293    }
294}
295
296/// When [`Context::form_field`](crate::Context::form_field) runs a field's
297/// validators.
298///
299/// Defaults to [`OnBlur`](ValidateTrigger::OnBlur), matching the behavior of
300/// `huh` and `bubbles/textinput`.
301///
302/// # Example
303///
304/// ```no_run
305/// # use slt::widgets::{FormField, ValidateTrigger, validators};
306/// let field = FormField::new("Email")
307///     .validate(validators::email())
308///     .on_change(); // validate as the user types
309/// assert_eq!(field.trigger, ValidateTrigger::OnChange);
310/// ```
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
312pub enum ValidateTrigger {
313    /// Validate on every value change (each keystroke).
314    OnChange,
315    /// Validate when the field loses focus. The default.
316    #[default]
317    OnBlur,
318    /// Never auto-validate; the app calls
319    /// [`FormState::validate_all`] or [`FormField::run_validators`] manually.
320    Manual,
321}
322
323/// A single form field with a label, an input, and its own validators.
324///
325/// Attach validators with the chainable [`validate`](Self::validate) builder
326/// (multiple allowed); choose when they run with [`on_change`](Self::on_change)
327/// / [`on_blur`](Self::on_blur). [`Context::form_field`](crate::Context::form_field)
328/// runs them automatically per [`trigger`](Self::trigger).
329///
330/// # Example
331///
332/// ```no_run
333/// # use slt::widgets::{FormField, validators};
334/// let field = FormField::new("Email")
335///     .placeholder("you@example.com")
336///     .validate(validators::required("required"))
337///     .validate(validators::email());
338/// # let _ = field;
339/// ```
340#[derive(Debug, Default)]
341pub struct FormField {
342    /// Field label shown above the input.
343    pub label: String,
344    /// Text input state for this field.
345    pub input: TextInputState,
346    /// Validation error shown below the input when present.
347    pub error: Option<String>,
348    /// When the field's validators run. Defaults to
349    /// [`ValidateTrigger::OnBlur`].
350    pub trigger: ValidateTrigger,
351    /// This field's validators. Mutate via [`validate`](Self::validate); run
352    /// via [`run_validators`](Self::run_validators).
353    validators: Vec<Validator>,
354    /// Whether the field's input held keyboard focus on the previous frame.
355    ///
356    /// [`Context::form_field`](crate::Context::form_field) uses the
357    /// focused → unfocused edge to detect blur for
358    /// [`ValidateTrigger::OnBlur`]. This is tracked here (rather than read from
359    /// the input's [`Response`]) because the `text_input` Response does not yet
360    /// carry the `lost_focus` signal on its container-assembled response.
361    was_focused: bool,
362    /// One in-flight async validation, if any. Polled each frame by
363    /// [`Context::form_field`](crate::Context::form_field).
364    #[cfg(feature = "async")]
365    pending: Option<AsyncValidation>,
366}
367
368impl FormField {
369    /// Create a new form field with the given label.
370    pub fn new(label: impl Into<String>) -> Self {
371        Self {
372            label: label.into(),
373            input: TextInputState::new(),
374            error: None,
375            trigger: ValidateTrigger::default(),
376            validators: Vec::new(),
377            was_focused: false,
378            #[cfg(feature = "async")]
379            pending: None,
380        }
381    }
382
383    /// Set placeholder text for this field's input.
384    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
385        self.input.placeholder = p.into();
386        self
387    }
388
389    /// Attach a validator closure (chainable; call multiple times to stack
390    /// validators — the first failure becomes the field error).
391    ///
392    /// The closure may capture state, unlike the deprecated positional
393    /// [`FormValidator`]. Built-ins live in
394    /// [`validators`](crate::widgets::validators).
395    ///
396    /// # Example
397    ///
398    /// ```no_run
399    /// # use slt::widgets::{FormField, validators};
400    /// let field = FormField::new("Name")
401    ///     .validate(validators::required("required"))
402    ///     .validate(validators::max_len(50, "too long"));
403    /// # let _ = field;
404    /// ```
405    pub fn validate(mut self, f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
406        self.validators.push(Validator::new(f));
407        self
408    }
409
410    /// Run this field's validators on every change (each keystroke).
411    pub fn on_change(mut self) -> Self {
412        self.trigger = ValidateTrigger::OnChange;
413        self
414    }
415
416    /// Run this field's validators when it loses focus (the default).
417    pub fn on_blur(mut self) -> Self {
418        self.trigger = ValidateTrigger::OnBlur;
419        self
420    }
421
422    /// Disable automatic validation; the app must call
423    /// [`run_validators`](Self::run_validators) or
424    /// [`FormState::validate_all`] explicitly.
425    pub fn manual(mut self) -> Self {
426        self.trigger = ValidateTrigger::Manual;
427        self
428    }
429
430    /// Number of validators attached to this field.
431    pub fn validator_count(&self) -> usize {
432        self.validators.len()
433    }
434
435    /// Run this field's validators now, setting [`error`](Self::error) to the
436    /// first failure (or clearing it on success).
437    ///
438    /// Returns `true` when the field is valid.
439    ///
440    /// # Example
441    ///
442    /// ```no_run
443    /// # use slt::widgets::{FormField, validators};
444    /// let mut field = FormField::new("Name").validate(validators::required("required"));
445    /// assert!(!field.run_validators()); // empty -> error
446    /// field.input.value = "Jane".into();
447    /// assert!(field.run_validators()); // non-empty -> ok
448    /// ```
449    pub fn run_validators(&mut self) -> bool {
450        self.error = self
451            .validators
452            .iter()
453            .find_map(|v| v.run(&self.input.value).err());
454        self.error.is_none()
455    }
456
457    /// Update the tracked focus edge and report whether the field *just* lost
458    /// focus this frame (a focused → unfocused transition).
459    ///
460    /// Called by [`Context::form_field`](crate::Context::form_field) each frame
461    /// with the input's current focus state. Kept crate-internal: blur
462    /// detection is an implementation detail of the form-field trigger plumbing.
463    pub(crate) fn observe_focus(&mut self, focused: bool) -> bool {
464        let lost = self.was_focused && !focused;
465        self.was_focused = focused;
466        lost
467    }
468
469    /// Spawn an asynchronous validation of the current value, replacing any
470    /// previously pending check.
471    ///
472    /// The future runs on the ambient tokio runtime; its `Result` is surfaced
473    /// as [`error`](Self::error) once [`poll_async`](Self::poll_async) (called
474    /// each frame by [`Context::form_field`](crate::Context::form_field)) sees
475    /// it complete.
476    ///
477    /// Requires the `async` feature.
478    ///
479    /// # Example
480    ///
481    /// ```no_run
482    /// # #[cfg(feature = "async")]
483    /// # async fn ex(field: &mut slt::widgets::FormField) {
484    /// let value = field.input.value.clone();
485    /// field.validate_async(async move {
486    ///     // e.g. hit a "username taken?" endpoint
487    ///     if value == "taken" { Err("already taken".into()) } else { Ok(()) }
488    /// });
489    /// # }
490    /// ```
491    #[cfg(feature = "async")]
492    pub fn validate_async<F>(&mut self, future: F)
493    where
494        F: std::future::Future<Output = Result<(), String>> + Send + 'static,
495    {
496        let (tx, rx) = tokio::sync::oneshot::channel();
497        tokio::spawn(async move {
498            let result = future.await;
499            let _ = tx.send(result);
500        });
501        self.pending = Some(AsyncValidation { rx });
502    }
503
504    /// Whether an async validation is currently in flight.
505    ///
506    /// Requires the `async` feature.
507    #[cfg(feature = "async")]
508    pub fn is_validating(&self) -> bool {
509        self.pending.is_some()
510    }
511
512    /// Poll the in-flight async validation (if any) without blocking.
513    ///
514    /// When the future has resolved, its result is written to
515    /// [`error`](Self::error) and the pending slot is cleared. Returns `true`
516    /// when a result was just applied this call.
517    ///
518    /// Requires the `async` feature.
519    #[cfg(feature = "async")]
520    pub fn poll_async(&mut self) -> bool {
521        use tokio::sync::oneshot::error::TryRecvError;
522        let Some(pending) = self.pending.as_mut() else {
523            return false;
524        };
525        match pending.rx.try_recv() {
526            Ok(result) => {
527                self.error = result.err();
528                self.pending = None;
529                true
530            }
531            Err(TryRecvError::Empty) => false,
532            Err(TryRecvError::Closed) => {
533                // Sender dropped without sending — treat as resolved (no error
534                // to surface) and clear the stuck pending slot.
535                self.pending = None;
536                true
537            }
538        }
539    }
540}
541
542/// State for a form with multiple fields.
543#[derive(Debug)]
544pub struct FormState {
545    /// Ordered list of form fields.
546    pub fields: Vec<FormField>,
547    /// Whether the form has been successfully submitted.
548    pub submitted: bool,
549}
550
551impl FormState {
552    /// Create an empty form state.
553    pub fn new() -> Self {
554        Self {
555            fields: Vec::new(),
556            submitted: false,
557        }
558    }
559
560    /// Add a field and return the updated form for chaining.
561    pub fn field(mut self, field: FormField) -> Self {
562        self.fields.push(field);
563        self
564    }
565
566    /// Whether the form is currently valid — no field holds an error.
567    ///
568    /// Reflects the last run of each field's validators (auto-triggered by
569    /// [`Context::form_field`](crate::Context::form_field) or run explicitly via
570    /// [`validate_all`](Self::validate_all)). It does not re-run validation.
571    ///
572    /// # Example
573    ///
574    /// ```no_run
575    /// # use slt::widgets::{FormField, FormState, validators};
576    /// let mut form = FormState::new().field(FormField::new("Name").validate(validators::required("required")));
577    /// assert!(form.is_valid()); // no validation run yet
578    /// form.validate_all();
579    /// assert!(!form.is_valid()); // empty Name failed
580    /// ```
581    pub fn is_valid(&self) -> bool {
582        self.fields.iter().all(|f| f.error.is_none())
583    }
584
585    /// Collect every current field error as `(field_index, message)` pairs.
586    ///
587    /// # Example
588    ///
589    /// ```no_run
590    /// # use slt::widgets::{FormField, FormState, validators};
591    /// let mut form = FormState::new().field(FormField::new("Name").validate(validators::required("required")));
592    /// form.validate_all();
593    /// assert_eq!(form.errors(), vec![(0, "required")]);
594    /// ```
595    pub fn errors(&self) -> Vec<(usize, &str)> {
596        self.fields
597            .iter()
598            .enumerate()
599            .filter_map(|(i, f)| f.error.as_deref().map(|e| (i, e)))
600            .collect()
601    }
602
603    /// Run every field's own validators, returning `true` when all pass.
604    ///
605    /// This is the replacement for the deprecated positional
606    /// [`validate`](Self::validate) — validators are co-located with their
607    /// fields, so there is no index slice to misalign.
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// # use slt::widgets::{FormField, FormState, validators};
613    /// let mut form = FormState::new()
614    ///     .field(FormField::new("Email").validate(validators::email()));
615    /// let ok = form.validate_all();
616    /// # let _ = ok;
617    /// ```
618    pub fn validate_all(&mut self) -> bool {
619        let mut ok = true;
620        for field in &mut self.fields {
621            ok &= field.run_validators();
622        }
623        ok
624    }
625
626    /// Apply cross-field validation rules.
627    ///
628    /// The closure receives the whole form and returns `(field_index, message)`
629    /// pairs; each pair sets that field's [`error`](FormField::error). Returns
630    /// `true` when the closure reports no errors. Useful for rules like
631    /// "confirm password must match password".
632    ///
633    /// # Example
634    ///
635    /// ```no_run
636    /// # use slt::widgets::{FormField, FormState};
637    /// let mut form = FormState::new()
638    ///     .field(FormField::new("Password"))
639    ///     .field(FormField::new("Confirm"));
640    /// let ok = form.validate_with(|f| {
641    ///     if f.value(0) != f.value(1) {
642    ///         vec![(1, "passwords must match".to_string())]
643    ///     } else {
644    ///         vec![]
645    ///     }
646    /// });
647    /// # let _ = ok;
648    /// ```
649    pub fn validate_with(
650        &mut self,
651        f: impl Fn(&FormState) -> Vec<(usize, String)>,
652    ) -> bool {
653        let extra = f(self);
654        for (i, msg) in &extra {
655            if let Some(field) = self.fields.get_mut(*i) {
656                field.error = Some(msg.clone());
657            }
658        }
659        extra.is_empty()
660    }
661
662    /// Validate all fields with a positional slice of function-pointer
663    /// validators.
664    ///
665    /// Returns `true` when all validations pass. A field whose index has no
666    /// matching validator is silently skipped.
667    #[deprecated(
668        since = "0.21.0",
669        note = "Attach validators per-field via FormField::validate and call validate_all(); positional slices misalign silently."
670    )]
671    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
672        let mut all_valid = true;
673        for (i, field) in self.fields.iter_mut().enumerate() {
674            if let Some(validator) = validators.get(i) {
675                match validator(&field.input.value) {
676                    Ok(()) => field.error = None,
677                    Err(msg) => {
678                        field.error = Some(msg);
679                        all_valid = false;
680                    }
681                }
682            }
683        }
684        all_valid
685    }
686
687    /// Get field value by index.
688    pub fn value(&self, index: usize) -> &str {
689        self.fields
690            .get(index)
691            .map(|f| f.input.value.as_str())
692            .unwrap_or("")
693    }
694}
695
696impl Default for FormState {
697    fn default() -> Self {
698        Self::new()
699    }
700}
701
702/// State for toast notification display.
703///
704/// Add messages with [`ToastState::info`], [`ToastState::success`],
705/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
706/// `Context::toast` each frame. Expired messages are removed automatically.
707#[derive(Debug, Clone)]
708pub struct ToastState {
709    /// Active toast messages, ordered oldest-first.
710    pub messages: Vec<ToastMessage>,
711}
712
713/// A single toast notification message.
714#[derive(Debug, Clone)]
715pub struct ToastMessage {
716    /// The text content of the notification.
717    pub text: String,
718    /// Severity level, used to choose the display color.
719    pub level: ToastLevel,
720    /// The tick at which this message was created.
721    pub created_tick: u64,
722    /// How many ticks the message remains visible.
723    pub duration_ticks: u64,
724}
725
726impl Default for ToastMessage {
727    fn default() -> Self {
728        Self {
729            text: String::new(),
730            level: ToastLevel::Info,
731            created_tick: 0,
732            duration_ticks: 30,
733        }
734    }
735}
736
737/// Severity level for a [`ToastMessage`].
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub enum ToastLevel {
740    /// Informational message (primary color).
741    Info,
742    /// Success message (success color).
743    Success,
744    /// Warning message (warning color).
745    Warning,
746    /// Error message (error color).
747    Error,
748}
749
750/// Severity level for alert widgets.
751#[non_exhaustive]
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753pub enum AlertLevel {
754    /// Informational alert.
755    Info,
756    /// Success alert.
757    Success,
758    /// Warning alert.
759    Warning,
760    /// Error alert.
761    Error,
762}
763
764impl ToastState {
765    /// Create an empty toast state with no messages.
766    pub fn new() -> Self {
767        Self {
768            messages: Vec::new(),
769        }
770    }
771
772    /// Push an informational toast visible for 30 ticks.
773    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
774        self.push(text, ToastLevel::Info, tick, 30);
775    }
776
777    /// Push a success toast visible for 30 ticks.
778    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
779        self.push(text, ToastLevel::Success, tick, 30);
780    }
781
782    /// Push a warning toast visible for 50 ticks.
783    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
784        self.push(text, ToastLevel::Warning, tick, 50);
785    }
786
787    /// Push an error toast visible for 80 ticks.
788    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
789        self.push(text, ToastLevel::Error, tick, 80);
790    }
791
792    /// Push a toast with a custom level and duration.
793    pub fn push(
794        &mut self,
795        text: impl Into<String>,
796        level: ToastLevel,
797        tick: u64,
798        duration_ticks: u64,
799    ) {
800        self.messages.push(ToastMessage {
801            text: text.into(),
802            level,
803            created_tick: tick,
804            duration_ticks,
805        });
806    }
807
808    /// Remove all messages whose display duration has elapsed.
809    ///
810    /// Called automatically by `Context::toast` before rendering.
811    pub fn cleanup(&mut self, current_tick: u64) {
812        self.messages.retain(|message| {
813            current_tick < message.created_tick.saturating_add(message.duration_ticks)
814        });
815    }
816}
817
818impl Default for ToastState {
819    fn default() -> Self {
820        Self::new()
821    }
822}
823
824/// Default maximum number of [`TextareaSnapshot`] entries kept in
825/// [`TextareaState::history`]. Used by [`TextareaState::new`] and the
826/// `Default` impl. Override per-instance via
827/// [`TextareaState::history_max`].
828pub(crate) const DEFAULT_TEXTAREA_HISTORY_MAX: usize = 100;
829
830/// Snapshot of textarea content + cursor for the undo/redo history stack.
831///
832/// One snapshot is pushed before every destructive mutation (char insert,
833/// delete, Enter, Backspace, paste). `Ctrl+Z` walks the index backward to a
834/// previous snapshot; `Ctrl+Y` walks it forward.
835///
836/// Crate-internal — the `pub(crate)` visibility keeps the history layout an
837/// implementation detail. Inspect via the public undo/redo behavior instead.
838#[derive(Debug, Clone)]
839pub(crate) struct TextareaSnapshot {
840    /// Lines of text at the time of the snapshot.
841    pub(crate) lines: Vec<String>,
842    /// Cursor row at the time of the snapshot.
843    pub(crate) cursor_row: usize,
844    /// Cursor column at the time of the snapshot.
845    pub(crate) cursor_col: usize,
846}
847
848/// State for a multi-line text area widget.
849///
850/// Pass a mutable reference to `Context::textarea` each frame along with the
851/// number of visible rows. The widget handles all keyboard events when focused.
852///
853/// # Undo / redo
854///
855/// `Ctrl+Z` undoes the most recent edit and `Ctrl+Y` redoes it. The widget
856/// pushes a snapshot before every destructive mutation (char insert, delete,
857/// Enter, Backspace, paste). Rapid character typing coalesces into a single
858/// undoable batch — only the first char of a typing burst pushes a snapshot.
859/// History is capped at [`history_max`](Self::history_max) entries (default
860/// `100`); the oldest snapshot is dropped when the cap is exceeded.
861///
862/// # Example
863///
864/// ```no_run
865/// # use slt::widgets::TextareaState;
866/// # slt::run(|ui: &mut slt::Context| {
867/// let mut state = TextareaState::new();
868/// // Type, then press Ctrl+Z to undo or Ctrl+Y to redo.
869/// ui.textarea(&mut state, 5);
870/// # });
871/// ```
872#[derive(Debug, Clone)]
873pub struct TextareaState {
874    /// The lines of text, one entry per line.
875    pub lines: Vec<String>,
876    /// Row index of the cursor (0-based, logical line).
877    pub cursor_row: usize,
878    /// Column index of the cursor within the current row (character index).
879    pub cursor_col: usize,
880    /// Maximum total character count across all lines.
881    pub max_length: Option<usize>,
882    /// When set, lines longer than this display-column width are soft-wrapped.
883    pub wrap_width: Option<u32>,
884    /// First visible visual line (managed internally by `textarea()`).
885    pub scroll_offset: usize,
886    /// Undo/redo snapshot stack. Newest entry is at the tip; the index walks
887    /// backward on `Ctrl+Z` and forward on `Ctrl+Y`.
888    pub(crate) history: Vec<TextareaSnapshot>,
889    /// Pointer into [`history`](Self::history) for the next undo target.
890    pub(crate) history_index: usize,
891    /// Maximum [`history`](Self::history) length before the oldest snapshot is
892    /// evicted. Defaults to [`DEFAULT_TEXTAREA_HISTORY_MAX`].
893    pub(crate) history_max: usize,
894    /// Whether the previous keypress was a `Char` insert. Used to coalesce
895    /// rapid typing into a single undoable burst — when true, the next `Char`
896    /// keypress does not push a snapshot.
897    pub(crate) last_was_char_insert: bool,
898}
899
900impl TextareaState {
901    /// Create an empty text area state with one blank line.
902    pub fn new() -> Self {
903        Self {
904            lines: vec![String::new()],
905            cursor_row: 0,
906            cursor_col: 0,
907            max_length: None,
908            wrap_width: None,
909            scroll_offset: 0,
910            history: Vec::new(),
911            history_index: 0,
912            history_max: DEFAULT_TEXTAREA_HISTORY_MAX,
913            last_was_char_insert: false,
914        }
915    }
916
917    /// Return all lines joined with newline characters.
918    pub fn value(&self) -> String {
919        self.lines.join("\n")
920    }
921
922    /// Replace the content with the given text, splitting on newlines.
923    ///
924    /// Resets the cursor to the beginning of the first line and clears the
925    /// undo history — programmatic replacement is treated as a fresh state,
926    /// not an undoable edit.
927    pub fn set_value(&mut self, text: impl Into<String>) {
928        let value = text.into();
929        self.lines = value.split('\n').map(str::to_string).collect();
930        if self.lines.is_empty() {
931            self.lines.push(String::new());
932        }
933        self.cursor_row = 0;
934        self.cursor_col = 0;
935        self.scroll_offset = 0;
936        self.history.clear();
937        self.history_index = 0;
938        self.last_was_char_insert = false;
939    }
940
941    /// Set the maximum allowed total character count.
942    pub fn max_length(mut self, len: usize) -> Self {
943        self.max_length = Some(len);
944        self
945    }
946
947    /// Enable soft word-wrap at the given display-column width.
948    pub fn word_wrap(mut self, width: u32) -> Self {
949        self.wrap_width = Some(width);
950        self
951    }
952
953    /// Override the maximum number of undo snapshots kept (default `100`).
954    ///
955    /// When the history exceeds this cap the oldest snapshot is dropped.
956    /// Setting `0` disables undo recording — the field is read every keypress.
957    pub fn history_max(mut self, cap: usize) -> Self {
958        self.history_max = cap;
959        self
960    }
961
962    /// Number of undo snapshots currently retained.
963    ///
964    /// Read-only — useful for tests and debugging the history cap. The cap
965    /// itself is set via [`history_max`](Self::history_max).
966    pub fn history_len(&self) -> usize {
967        self.history.len()
968    }
969
970    /// Maximum number of undo snapshots retained.
971    ///
972    /// Mirrors [`history_max`](Self::history_max) (the builder setter) but as
973    /// a getter — useful for tests asserting the cap stays bounded.
974    pub fn history_cap(&self) -> usize {
975        self.history_max
976    }
977
978    /// Push a snapshot of the current content + cursor onto the undo stack.
979    ///
980    /// Truncates any redo tail beyond `history_index`, appends the snapshot,
981    /// and caps the stack at [`history_max`](Self::history_max) by dropping the
982    /// oldest entry. `history_index` is left pointing one past the newest
983    /// snapshot so the next `Ctrl+Z` returns to the just-pushed state.
984    pub(crate) fn push_history(&mut self) {
985        if self.history_max == 0 {
986            return;
987        }
988        // Drop any redo tail — a fresh edit invalidates the redo branch.
989        if self.history_index < self.history.len() {
990            self.history.truncate(self.history_index);
991        }
992        self.history.push(TextareaSnapshot {
993            lines: self.lines.clone(),
994            cursor_row: self.cursor_row,
995            cursor_col: self.cursor_col,
996        });
997        // Evict oldest when over the cap. `Vec::remove(0)` is O(n) but the
998        // history cap is small (default 100) and this only runs at the cap
999        // boundary, so the cost is bounded.
1000        while self.history.len() > self.history_max {
1001            self.history.remove(0);
1002        }
1003        self.history_index = self.history.len();
1004    }
1005
1006    /// Walk the undo index back one step and apply the snapshot.
1007    ///
1008    /// No-op when the history is empty or already at the start. Returns `true`
1009    /// when a snapshot was applied.
1010    pub(crate) fn undo(&mut self) -> bool {
1011        if self.history.is_empty() || self.history_index == 0 {
1012            return false;
1013        }
1014        // First Ctrl+Z after edits captures the current (unsaved) tip so the
1015        // user can redo back to it; subsequent presses walk down the stack.
1016        if self.history_index == self.history.len() {
1017            self.history.push(TextareaSnapshot {
1018                lines: self.lines.clone(),
1019                cursor_row: self.cursor_row,
1020                cursor_col: self.cursor_col,
1021            });
1022        }
1023        self.history_index -= 1;
1024        let snap = &self.history[self.history_index];
1025        self.lines = snap.lines.clone();
1026        self.cursor_row = snap.cursor_row;
1027        self.cursor_col = snap.cursor_col;
1028        true
1029    }
1030
1031    /// Walk the undo index forward one step and apply the snapshot.
1032    ///
1033    /// No-op when already at the redo tip. Returns `true` when a snapshot was
1034    /// applied.
1035    pub(crate) fn redo(&mut self) -> bool {
1036        if self.history_index + 1 >= self.history.len() {
1037            return false;
1038        }
1039        self.history_index += 1;
1040        let snap = &self.history[self.history_index];
1041        self.lines = snap.lines.clone();
1042        self.cursor_row = snap.cursor_row;
1043        self.cursor_col = snap.cursor_col;
1044        true
1045    }
1046}
1047
1048impl Default for TextareaState {
1049    fn default() -> Self {
1050        Self::new()
1051    }
1052}
1053
1054/// State for an animated spinner widget.
1055///
1056/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
1057/// `Context::spinner` each frame. The frame advances automatically with the
1058/// tick counter.
1059#[derive(Debug, Clone)]
1060pub struct SpinnerState {
1061    chars: &'static [char],
1062}
1063
1064static DOTS_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1065static LINE_CHARS: &[char] = &['|', '/', '-', '\\'];
1066
1067impl SpinnerState {
1068    /// Create a dots-style spinner using braille characters.
1069    ///
1070    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
1071    pub fn dots() -> Self {
1072        Self { chars: DOTS_CHARS }
1073    }
1074
1075    /// Create a line-style spinner using ASCII characters.
1076    ///
1077    /// Cycles through: `| / - \`
1078    pub fn line() -> Self {
1079        Self { chars: LINE_CHARS }
1080    }
1081
1082    /// Return the spinner character for the given tick.
1083    pub fn frame(&self, tick: u64) -> char {
1084        if self.chars.is_empty() {
1085            return ' ';
1086        }
1087        self.chars[tick as usize % self.chars.len()]
1088    }
1089}
1090
1091impl Default for SpinnerState {
1092    fn default() -> Self {
1093        Self::dots()
1094    }
1095}
1096
1097/// State for a numeric stepper field (clamp + step, integer or float).
1098///
1099/// A numeric stepper renders the value as an editable field with `▾`/`▴`
1100/// affordances. When focused it adjusts via Up/Down (or `k`/`j`) and the scroll
1101/// wheel, or the user can type a value directly and press `Enter` to commit it.
1102/// The committed [`value`](NumberInputState::value) is always clamped into
1103/// `[min, max]` (and rounded to a whole number in integer mode).
1104///
1105/// Create with [`NumberInputState::new`] (float) or
1106/// [`NumberInputState::integer`], then pass to
1107/// [`Context::number_input`](crate::Context::number_input) each frame.
1108///
1109/// # Example
1110///
1111/// ```no_run
1112/// # use slt::widgets::NumberInputState;
1113/// # slt::run(|ui: &mut slt::Context| {
1114/// let mut qty = NumberInputState::integer(3, 0, 10).step(1.0);
1115/// let r = ui.number_input(&mut qty);
1116/// if r.changed {
1117///     // qty.value was adjusted this frame
1118/// }
1119/// # });
1120/// ```
1121///
1122/// Available since `0.21.0`.
1123#[derive(Debug, Clone)]
1124pub struct NumberInputState {
1125    /// Committed numeric value, always within `[min, max]`.
1126    pub value: f64,
1127    /// Inclusive lower bound.
1128    pub min: f64,
1129    /// Inclusive upper bound.
1130    pub max: f64,
1131    /// Increment applied per Up/Down/scroll tick.
1132    pub step: f64,
1133    /// When true, the value is whole-number only and rendered without a decimal point.
1134    pub integer: bool,
1135    /// In-progress typed text; `Some` while the user is editing the field.
1136    pub editing: Option<String>,
1137    /// Last parse failure from `Enter` on an invalid buffer, if any.
1138    pub parse_error: Option<String>,
1139}
1140
1141impl NumberInputState {
1142    /// Float stepper with the given starting value and inclusive range.
1143    ///
1144    /// `value` is clamped into `[min, max]` immediately. If `min > max` the two
1145    /// bounds are swapped so the range is always well-formed.
1146    ///
1147    /// # Example
1148    ///
1149    /// ```
1150    /// # use slt::widgets::NumberInputState;
1151    /// let s = NumberInputState::new(1.5, 0.0, 10.0);
1152    /// assert_eq!(s.value, 1.5);
1153    /// assert!(!s.integer);
1154    /// ```
1155    pub fn new(value: f64, min: f64, max: f64) -> Self {
1156        let (min, max) = if min <= max { (min, max) } else { (max, min) };
1157        Self {
1158            value: value.clamp(min, max),
1159            min,
1160            max,
1161            step: 1.0,
1162            integer: false,
1163            editing: None,
1164            parse_error: None,
1165        }
1166    }
1167
1168    /// Integer stepper (rounds value, renders without a decimal point).
1169    ///
1170    /// Convenience constructor that sets `integer = true` and a default step of
1171    /// `1.0`. `value` is clamped into `[min, max]`.
1172    ///
1173    /// # Example
1174    ///
1175    /// ```
1176    /// # use slt::widgets::NumberInputState;
1177    /// let s = NumberInputState::integer(42, 0, 100);
1178    /// assert_eq!(s.value, 42.0);
1179    /// assert!(s.integer);
1180    /// ```
1181    pub fn integer(value: i64, min: i64, max: i64) -> Self {
1182        let mut s = Self::new(value as f64, min as f64, max as f64);
1183        s.integer = true;
1184        s
1185    }
1186
1187    /// Set the per-tick increment (consumes self, builder style).
1188    ///
1189    /// Negative or zero steps are coerced to `0.0` (no adjustment).
1190    ///
1191    /// # Example
1192    ///
1193    /// ```
1194    /// # use slt::widgets::NumberInputState;
1195    /// let s = NumberInputState::new(0.0, 0.0, 1.0).step(0.1);
1196    /// assert!((s.step - 0.1).abs() < f64::EPSILON);
1197    /// ```
1198    pub fn step(mut self, step: f64) -> Self {
1199        self.step = step.max(0.0);
1200        self
1201    }
1202
1203    /// Clamp `value` into `[min, max]` (and round if `integer`).
1204    ///
1205    /// Used internally after every adjustment and typed commit, and exposed so
1206    /// callers that mutate [`value`](NumberInputState::value) directly can
1207    /// re-normalize it.
1208    ///
1209    /// # Example
1210    ///
1211    /// ```
1212    /// # use slt::widgets::NumberInputState;
1213    /// let mut s = NumberInputState::integer(0, 0, 10);
1214    /// s.value = 99.0;
1215    /// assert_eq!(s.clamped(), 10.0);
1216    /// s.value = 3.7;
1217    /// assert_eq!(s.clamped(), 4.0);
1218    /// ```
1219    pub fn clamped(&self) -> f64 {
1220        let v = self.value.clamp(self.min, self.max);
1221        if self.integer {
1222            v.round()
1223        } else {
1224            v
1225        }
1226    }
1227}
1228
1229impl Default for NumberInputState {
1230    fn default() -> Self {
1231        Self::new(0.0, 0.0, 100.0)
1232    }
1233}