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`] 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")]
285#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
286pub struct AsyncValidation {
287    rx: tokio::sync::oneshot::Receiver<Result<(), String>>,
288    join: tokio::task::JoinHandle<()>,
289}
290
291#[cfg(feature = "async")]
292impl std::fmt::Debug for AsyncValidation {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.write_str("AsyncValidation(<pending>)")
295    }
296}
297
298#[cfg(feature = "async")]
299impl Drop for AsyncValidation {
300    fn drop(&mut self) {
301        self.join.abort();
302    }
303}
304
305/// When [`Context::form_field`](crate::Context::form_field) runs a field's
306/// validators.
307///
308/// Defaults to [`OnBlur`](ValidateTrigger::OnBlur), matching the behavior of
309/// `huh` and `bubbles/textinput`.
310///
311/// # Example
312///
313/// ```no_run
314/// # use slt::widgets::{FormField, ValidateTrigger, validators};
315/// let field = FormField::new("Email")
316///     .validate(validators::email())
317///     .on_change(); // validate as the user types
318/// assert_eq!(field.trigger, ValidateTrigger::OnChange);
319/// ```
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
321pub enum ValidateTrigger {
322    /// Validate on every value change (each keystroke).
323    OnChange,
324    /// Validate when the field loses focus. The default.
325    #[default]
326    OnBlur,
327    /// Never auto-validate; the app calls
328    /// [`FormState::validate_all`] or [`FormField::run_validators`] manually.
329    Manual,
330}
331
332/// A single form field with a label, an input, and its own validators.
333///
334/// Attach validators with the chainable [`validate`](Self::validate) builder
335/// (multiple allowed); choose when they run with [`on_change`](Self::on_change)
336/// / [`on_blur`](Self::on_blur). [`Context::form_field`](crate::Context::form_field)
337/// runs them automatically per [`trigger`](Self::trigger).
338///
339/// # Example
340///
341/// ```no_run
342/// # use slt::widgets::{FormField, validators};
343/// let field = FormField::new("Email")
344///     .placeholder("you@example.com")
345///     .validate(validators::required("required"))
346///     .validate(validators::email());
347/// # let _ = field;
348/// ```
349#[derive(Debug, Default)]
350pub struct FormField {
351    /// Field label shown above the input.
352    pub label: String,
353    /// Text input state for this field.
354    pub input: TextInputState,
355    /// Validation error shown below the input when present.
356    pub error: Option<String>,
357    /// When the field's validators run. Defaults to
358    /// [`ValidateTrigger::OnBlur`].
359    pub trigger: ValidateTrigger,
360    /// This field's validators. Mutate via [`validate`](Self::validate); run
361    /// via [`run_validators`](Self::run_validators).
362    validators: Vec<Validator>,
363    /// Whether the field's input held keyboard focus on the previous frame.
364    ///
365    /// [`Context::form_field`](crate::Context::form_field) uses the
366    /// focused → unfocused edge to detect blur for
367    /// [`ValidateTrigger::OnBlur`]. This is tracked here (rather than read from
368    /// the input's [`Response`]) because the `text_input` Response does not yet
369    /// carry the `lost_focus` signal on its container-assembled response.
370    was_focused: bool,
371    /// One in-flight async validation, if any. Polled each frame by
372    /// [`Context::form_field`](crate::Context::form_field).
373    #[cfg(feature = "async")]
374    pending: Option<AsyncValidation>,
375}
376
377impl FormField {
378    /// Create a new form field with the given label.
379    pub fn new(label: impl Into<String>) -> Self {
380        Self {
381            label: label.into(),
382            input: TextInputState::new(),
383            error: None,
384            trigger: ValidateTrigger::default(),
385            validators: Vec::new(),
386            was_focused: false,
387            #[cfg(feature = "async")]
388            pending: None,
389        }
390    }
391
392    /// Set placeholder text for this field's input.
393    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
394        self.input.placeholder = p.into();
395        self
396    }
397
398    /// Attach a validator closure (chainable; call multiple times to stack
399    /// validators — the first failure becomes the field error).
400    ///
401    /// The closure may capture state, unlike the deprecated positional
402    /// [`FormValidator`]. Built-ins live in
403    /// [`validators`].
404    ///
405    /// # Example
406    ///
407    /// ```no_run
408    /// # use slt::widgets::{FormField, validators};
409    /// let field = FormField::new("Name")
410    ///     .validate(validators::required("required"))
411    ///     .validate(validators::max_len(50, "too long"));
412    /// # let _ = field;
413    /// ```
414    pub fn validate(mut self, f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
415        self.validators.push(Validator::new(f));
416        self
417    }
418
419    /// Run this field's validators on every change (each keystroke).
420    pub fn on_change(mut self) -> Self {
421        self.trigger = ValidateTrigger::OnChange;
422        self
423    }
424
425    /// Run this field's validators when it loses focus (the default).
426    pub fn on_blur(mut self) -> Self {
427        self.trigger = ValidateTrigger::OnBlur;
428        self
429    }
430
431    /// Disable automatic validation; the app must call
432    /// [`run_validators`](Self::run_validators) or
433    /// [`FormState::validate_all`] explicitly.
434    pub fn manual(mut self) -> Self {
435        self.trigger = ValidateTrigger::Manual;
436        self
437    }
438
439    /// Number of validators attached to this field.
440    pub fn validator_count(&self) -> usize {
441        self.validators.len()
442    }
443
444    /// Run this field's validators now, setting [`error`](Self::error) to the
445    /// first failure (or clearing it on success).
446    ///
447    /// Returns `true` when the field is valid.
448    ///
449    /// # Example
450    ///
451    /// ```no_run
452    /// # use slt::widgets::{FormField, validators};
453    /// let mut field = FormField::new("Name").validate(validators::required("required"));
454    /// assert!(!field.run_validators()); // empty -> error
455    /// field.input.value = "Jane".into();
456    /// assert!(field.run_validators()); // non-empty -> ok
457    /// ```
458    pub fn run_validators(&mut self) -> bool {
459        self.error = self
460            .validators
461            .iter()
462            .find_map(|v| v.run(&self.input.value).err());
463        self.error.is_none()
464    }
465
466    /// Update the tracked focus edge and report whether the field *just* lost
467    /// focus this frame (a focused → unfocused transition).
468    ///
469    /// Called by [`Context::form_field`](crate::Context::form_field) each frame
470    /// with the input's current focus state. Kept crate-internal: blur
471    /// detection is an implementation detail of the form-field trigger plumbing.
472    pub(crate) fn observe_focus(&mut self, focused: bool) -> bool {
473        let lost = self.was_focused && !focused;
474        self.was_focused = focused;
475        lost
476    }
477
478    /// Spawn an asynchronous validation of the current value, replacing any
479    /// previously pending check.
480    ///
481    /// The future runs on the ambient tokio runtime; its `Result` is surfaced
482    /// as [`error`](Self::error) once [`poll_async`](Self::poll_async) (called
483    /// each frame by [`Context::form_field`](crate::Context::form_field)) sees
484    /// it complete.
485    ///
486    /// Requires the `async` feature.
487    ///
488    /// # Example
489    ///
490    /// ```no_run
491    /// # #[cfg(feature = "async")]
492    /// # async fn ex(field: &mut slt::widgets::FormField) {
493    /// let value = field.input.value.clone();
494    /// field.validate_async(async move {
495    ///     // e.g. hit a "username taken?" endpoint
496    ///     if value == "taken" { Err("already taken".into()) } else { Ok(()) }
497    /// });
498    /// # }
499    /// ```
500    #[cfg(feature = "async")]
501    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
502    pub fn validate_async<F>(&mut self, future: F)
503    where
504        F: std::future::Future<Output = Result<(), String>> + Send + 'static,
505    {
506        let (tx, rx) = tokio::sync::oneshot::channel();
507        let join = tokio::spawn(async move {
508            let result = future.await;
509            let _ = tx.send(result);
510        });
511        self.pending = Some(AsyncValidation { rx, join });
512    }
513
514    /// Whether an async validation is currently in flight.
515    ///
516    /// Requires the `async` feature.
517    #[cfg(feature = "async")]
518    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
519    pub fn is_validating(&self) -> bool {
520        self.pending.is_some()
521    }
522
523    /// Poll the in-flight async validation (if any) without blocking.
524    ///
525    /// When the future has resolved, its result is written to
526    /// [`error`](Self::error) and the pending slot is cleared. Returns `true`
527    /// when a result was just applied this call.
528    ///
529    /// Requires the `async` feature.
530    #[cfg(feature = "async")]
531    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
532    pub fn poll_async(&mut self) -> bool {
533        use tokio::sync::oneshot::error::TryRecvError;
534        let Some(pending) = self.pending.as_mut() else {
535            return false;
536        };
537        match pending.rx.try_recv() {
538            Ok(result) => {
539                self.error = result.err();
540                self.pending = None;
541                true
542            }
543            Err(TryRecvError::Empty) => false,
544            Err(TryRecvError::Closed) => {
545                // Sender dropped without sending — treat as resolved (no error
546                // to surface) and clear the stuck pending slot.
547                self.pending = None;
548                true
549            }
550        }
551    }
552}
553
554/// State for a form with multiple fields.
555#[derive(Debug)]
556pub struct FormState {
557    /// Ordered list of form fields.
558    pub fields: Vec<FormField>,
559    /// Whether the form has been successfully submitted.
560    pub submitted: bool,
561}
562
563impl FormState {
564    /// Create an empty form state.
565    pub fn new() -> Self {
566        Self {
567            fields: Vec::new(),
568            submitted: false,
569        }
570    }
571
572    /// Add a field and return the updated form for chaining.
573    pub fn field(mut self, field: FormField) -> Self {
574        self.fields.push(field);
575        self
576    }
577
578    /// Whether the form is currently valid — no field holds an error.
579    ///
580    /// Reflects the last run of each field's validators (auto-triggered by
581    /// [`Context::form_field`](crate::Context::form_field) or run explicitly via
582    /// [`validate_all`](Self::validate_all)). It does not re-run validation.
583    ///
584    /// # Example
585    ///
586    /// ```no_run
587    /// # use slt::widgets::{FormField, FormState, validators};
588    /// let mut form = FormState::new().field(FormField::new("Name").validate(validators::required("required")));
589    /// assert!(form.is_valid()); // no validation run yet
590    /// form.validate_all();
591    /// assert!(!form.is_valid()); // empty Name failed
592    /// ```
593    pub fn is_valid(&self) -> bool {
594        self.fields.iter().all(|f| f.error.is_none())
595    }
596
597    /// Collect every current field error as `(field_index, message)` pairs.
598    ///
599    /// # Example
600    ///
601    /// ```no_run
602    /// # use slt::widgets::{FormField, FormState, validators};
603    /// let mut form = FormState::new().field(FormField::new("Name").validate(validators::required("required")));
604    /// form.validate_all();
605    /// assert_eq!(form.errors(), vec![(0, "required")]);
606    /// ```
607    pub fn errors(&self) -> Vec<(usize, &str)> {
608        self.fields
609            .iter()
610            .enumerate()
611            .filter_map(|(i, f)| f.error.as_deref().map(|e| (i, e)))
612            .collect()
613    }
614
615    /// Run every field's own validators, returning `true` when all pass.
616    ///
617    /// This is the replacement for the deprecated positional
618    /// [`validate`](Self::validate) — validators are co-located with their
619    /// fields, so there is no index slice to misalign.
620    ///
621    /// # Example
622    ///
623    /// ```no_run
624    /// # use slt::widgets::{FormField, FormState, validators};
625    /// let mut form = FormState::new()
626    ///     .field(FormField::new("Email").validate(validators::email()));
627    /// let ok = form.validate_all();
628    /// # let _ = ok;
629    /// ```
630    pub fn validate_all(&mut self) -> bool {
631        let mut ok = true;
632        for field in &mut self.fields {
633            ok &= field.run_validators();
634        }
635        ok
636    }
637
638    /// Apply cross-field validation rules.
639    ///
640    /// The closure receives the whole form and returns `(field_index, message)`
641    /// pairs; each pair sets that field's [`error`](FormField::error). Returns
642    /// `true` when the closure reports no errors. Useful for rules like
643    /// "confirm password must match password".
644    ///
645    /// # Example
646    ///
647    /// ```no_run
648    /// # use slt::widgets::{FormField, FormState};
649    /// let mut form = FormState::new()
650    ///     .field(FormField::new("Password"))
651    ///     .field(FormField::new("Confirm"));
652    /// let ok = form.validate_with(|f| {
653    ///     if f.value(0) != f.value(1) {
654    ///         vec![(1, "passwords must match".to_string())]
655    ///     } else {
656    ///         vec![]
657    ///     }
658    /// });
659    /// # let _ = ok;
660    /// ```
661    pub fn validate_with(
662        &mut self,
663        f: impl Fn(&FormState) -> Vec<(usize, String)>,
664    ) -> bool {
665        let extra = f(self);
666        for (i, msg) in &extra {
667            if let Some(field) = self.fields.get_mut(*i) {
668                field.error = Some(msg.clone());
669            }
670        }
671        extra.is_empty()
672    }
673
674    /// Validate all fields with a positional slice of function-pointer
675    /// validators.
676    ///
677    /// Returns `true` when all validations pass. A field whose index has no
678    /// matching validator is silently skipped.
679    #[deprecated(
680        since = "0.21.0",
681        note = "Attach validators per-field via FormField::validate and call validate_all(); positional slices misalign silently."
682    )]
683    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
684        let mut all_valid = true;
685        for (i, field) in self.fields.iter_mut().enumerate() {
686            if let Some(validator) = validators.get(i) {
687                match validator(&field.input.value) {
688                    Ok(()) => field.error = None,
689                    Err(msg) => {
690                        field.error = Some(msg);
691                        all_valid = false;
692                    }
693                }
694            }
695        }
696        all_valid
697    }
698
699    /// Get field value by index.
700    pub fn value(&self, index: usize) -> &str {
701        self.fields
702            .get(index)
703            .map(|f| f.input.value.as_str())
704            .unwrap_or("")
705    }
706}
707
708impl Default for FormState {
709    fn default() -> Self {
710        Self::new()
711    }
712}
713
714#[cfg(all(test, feature = "async"))]
715mod async_validation_tests {
716    use super::FormField;
717    use std::sync::Arc;
718    use std::sync::atomic::{AtomicBool, Ordering};
719    use std::time::Duration;
720
721    #[tokio::test]
722    async fn replacing_async_validation_aborts_previous_task() {
723        let completed = Arc::new(AtomicBool::new(false));
724        let completed_in_task = Arc::clone(&completed);
725        let mut field = FormField::new("Username");
726
727        field.validate_async(async move {
728            tokio::time::sleep(Duration::from_millis(50)).await;
729            completed_in_task.store(true, Ordering::SeqCst);
730            Ok(())
731        });
732        field.validate_async(async { Ok(()) });
733
734        tokio::time::sleep(Duration::from_millis(120)).await;
735        assert!(
736            !completed.load(Ordering::SeqCst),
737            "superseded validation task must be aborted"
738        );
739    }
740
741    #[tokio::test]
742    async fn dropping_field_aborts_pending_validation_task() {
743        let completed = Arc::new(AtomicBool::new(false));
744        let completed_in_task = Arc::clone(&completed);
745        let mut field = FormField::new("Username");
746
747        field.validate_async(async move {
748            tokio::time::sleep(Duration::from_millis(50)).await;
749            completed_in_task.store(true, Ordering::SeqCst);
750            Ok(())
751        });
752        drop(field);
753
754        tokio::time::sleep(Duration::from_millis(120)).await;
755        assert!(
756            !completed.load(Ordering::SeqCst),
757            "dropping a field must abort its pending validation task"
758        );
759    }
760}
761
762/// State for toast notification display.
763///
764/// Add messages with [`ToastState::info`], [`ToastState::success`],
765/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
766/// `Context::toast` each frame. Expired messages are removed automatically.
767#[derive(Debug, Clone)]
768pub struct ToastState {
769    /// Active toast messages, ordered oldest-first.
770    pub messages: Vec<ToastMessage>,
771}
772
773/// A single toast notification message.
774#[derive(Debug, Clone)]
775pub struct ToastMessage {
776    /// The text content of the notification.
777    pub text: String,
778    /// Severity level, used to choose the display color.
779    pub level: ToastLevel,
780    /// The tick at which this message was created.
781    pub created_tick: u64,
782    /// How many ticks the message remains visible.
783    pub duration_ticks: u64,
784}
785
786impl Default for ToastMessage {
787    fn default() -> Self {
788        Self {
789            text: String::new(),
790            level: ToastLevel::Info,
791            created_tick: 0,
792            duration_ticks: 30,
793        }
794    }
795}
796
797/// Severity level for a [`ToastMessage`].
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
799pub enum ToastLevel {
800    /// Informational message (primary color).
801    Info,
802    /// Success message (success color).
803    Success,
804    /// Warning message (warning color).
805    Warning,
806    /// Error message (error color).
807    Error,
808}
809
810/// Severity level for alert widgets.
811#[non_exhaustive]
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813pub enum AlertLevel {
814    /// Informational alert.
815    Info,
816    /// Success alert.
817    Success,
818    /// Warning alert.
819    Warning,
820    /// Error alert.
821    Error,
822}
823
824impl ToastState {
825    /// Create an empty toast state with no messages.
826    pub fn new() -> Self {
827        Self {
828            messages: Vec::new(),
829        }
830    }
831
832    /// Push an informational toast visible for 30 ticks.
833    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
834        self.push(text, ToastLevel::Info, tick, 30);
835    }
836
837    /// Push a success toast visible for 30 ticks.
838    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
839        self.push(text, ToastLevel::Success, tick, 30);
840    }
841
842    /// Push a warning toast visible for 50 ticks.
843    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
844        self.push(text, ToastLevel::Warning, tick, 50);
845    }
846
847    /// Push an error toast visible for 80 ticks.
848    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
849        self.push(text, ToastLevel::Error, tick, 80);
850    }
851
852    /// Push a toast with a custom level and duration.
853    pub fn push(
854        &mut self,
855        text: impl Into<String>,
856        level: ToastLevel,
857        tick: u64,
858        duration_ticks: u64,
859    ) {
860        self.messages.push(ToastMessage {
861            text: text.into(),
862            level,
863            created_tick: tick,
864            duration_ticks,
865        });
866    }
867
868    /// Remove all messages whose display duration has elapsed.
869    ///
870    /// Called automatically by `Context::toast` before rendering.
871    pub fn cleanup(&mut self, current_tick: u64) {
872        self.messages.retain(|message| {
873            current_tick < message.created_tick.saturating_add(message.duration_ticks)
874        });
875    }
876}
877
878impl Default for ToastState {
879    fn default() -> Self {
880        Self::new()
881    }
882}
883
884/// Default maximum number of [`TextareaSnapshot`] entries kept in
885/// [`TextareaState::history`]. Used by [`TextareaState::new`] and the
886/// `Default` impl. Override per-instance via
887/// [`TextareaState::history_max`].
888pub(crate) const DEFAULT_TEXTAREA_HISTORY_MAX: usize = 100;
889
890/// Snapshot of textarea content + cursor for the undo/redo history stack.
891///
892/// One snapshot is pushed before every destructive mutation (char insert,
893/// delete, Enter, Backspace, paste). `Ctrl+Z` walks the index backward to a
894/// previous snapshot; `Ctrl+Y` walks it forward.
895///
896/// Crate-internal — the `pub(crate)` visibility keeps the history layout an
897/// implementation detail. Inspect via the public undo/redo behavior instead.
898#[derive(Debug, Clone)]
899pub(crate) struct TextareaSnapshot {
900    /// Lines of text at the time of the snapshot.
901    pub(crate) lines: Vec<String>,
902    /// Cursor row at the time of the snapshot.
903    pub(crate) cursor_row: usize,
904    /// Cursor column at the time of the snapshot.
905    pub(crate) cursor_col: usize,
906}
907
908/// State for a multi-line text area widget.
909///
910/// Pass a mutable reference to `Context::textarea` each frame along with the
911/// number of visible rows. The widget handles all keyboard events when focused.
912///
913/// # Undo / redo
914///
915/// `Ctrl+Z` undoes the most recent edit and `Ctrl+Y` redoes it. The widget
916/// pushes a snapshot before every destructive mutation (char insert, delete,
917/// Enter, Backspace, paste). Rapid character typing coalesces into a single
918/// undoable batch — only the first char of a typing burst pushes a snapshot.
919/// History is capped at [`history_max`](Self::history_max) entries (default
920/// `100`); the oldest snapshot is dropped when the cap is exceeded.
921///
922/// # Example
923///
924/// ```no_run
925/// # use slt::widgets::TextareaState;
926/// # slt::run(|ui: &mut slt::Context| {
927/// let mut state = TextareaState::new();
928/// // Type, then press Ctrl+Z to undo or Ctrl+Y to redo.
929/// ui.textarea(&mut state, 5);
930/// # });
931/// ```
932#[derive(Debug, Clone)]
933pub struct TextareaState {
934    /// The lines of text, one entry per line.
935    pub lines: Vec<String>,
936    /// Row index of the cursor (0-based, logical line).
937    pub cursor_row: usize,
938    /// Column index of the cursor within the current row (character index).
939    pub cursor_col: usize,
940    /// Maximum total character count across all lines.
941    pub max_length: Option<usize>,
942    /// When set, lines longer than this display-column width are soft-wrapped.
943    pub wrap_width: Option<u32>,
944    /// First visible visual line (managed internally by `textarea()`).
945    pub scroll_offset: usize,
946    /// Undo/redo snapshot stack. Newest entry is at the tip; the index walks
947    /// backward on `Ctrl+Z` and forward on `Ctrl+Y`.
948    pub(crate) history: Vec<TextareaSnapshot>,
949    /// Pointer into [`history`](Self::history) for the next undo target.
950    pub(crate) history_index: usize,
951    /// Maximum [`history`](Self::history) length before the oldest snapshot is
952    /// evicted. Defaults to [`DEFAULT_TEXTAREA_HISTORY_MAX`].
953    pub(crate) history_max: usize,
954    /// Whether the previous keypress was a `Char` insert. Used to coalesce
955    /// rapid typing into a single undoable burst — when true, the next `Char`
956    /// keypress does not push a snapshot.
957    pub(crate) last_was_char_insert: bool,
958}
959
960impl TextareaState {
961    /// Create an empty text area state with one blank line.
962    pub fn new() -> Self {
963        Self {
964            lines: vec![String::new()],
965            cursor_row: 0,
966            cursor_col: 0,
967            max_length: None,
968            wrap_width: None,
969            scroll_offset: 0,
970            history: Vec::new(),
971            history_index: 0,
972            history_max: DEFAULT_TEXTAREA_HISTORY_MAX,
973            last_was_char_insert: false,
974        }
975    }
976
977    /// Return all lines joined with newline characters.
978    pub fn value(&self) -> String {
979        self.lines.join("\n")
980    }
981
982    /// Replace the content with the given text, splitting on newlines.
983    ///
984    /// Resets the cursor to the beginning of the first line and clears the
985    /// undo history — programmatic replacement is treated as a fresh state,
986    /// not an undoable edit.
987    pub fn set_value(&mut self, text: impl Into<String>) {
988        let value = text.into();
989        self.lines = value.split('\n').map(str::to_string).collect();
990        if self.lines.is_empty() {
991            self.lines.push(String::new());
992        }
993        self.cursor_row = 0;
994        self.cursor_col = 0;
995        self.scroll_offset = 0;
996        self.history.clear();
997        self.history_index = 0;
998        self.last_was_char_insert = false;
999    }
1000
1001    /// Set the maximum allowed total character count.
1002    pub fn max_length(mut self, len: usize) -> Self {
1003        self.max_length = Some(len);
1004        self
1005    }
1006
1007    /// Enable soft word-wrap at the given display-column width.
1008    pub fn word_wrap(mut self, width: u32) -> Self {
1009        self.wrap_width = Some(width);
1010        self
1011    }
1012
1013    /// Override the maximum number of undo snapshots kept (default `100`).
1014    ///
1015    /// When the history exceeds this cap the oldest snapshot is dropped.
1016    /// Setting `0` disables undo recording — the field is read every keypress.
1017    pub fn history_max(mut self, cap: usize) -> Self {
1018        self.history_max = cap;
1019        self
1020    }
1021
1022    /// Number of undo snapshots currently retained.
1023    ///
1024    /// Read-only — useful for tests and debugging the history cap. The cap
1025    /// itself is set via [`history_max`](Self::history_max).
1026    pub fn history_len(&self) -> usize {
1027        self.history.len()
1028    }
1029
1030    /// Maximum number of undo snapshots retained.
1031    ///
1032    /// Mirrors [`history_max`](Self::history_max) (the builder setter) but as
1033    /// a getter — useful for tests asserting the cap stays bounded.
1034    pub fn history_cap(&self) -> usize {
1035        self.history_max
1036    }
1037
1038    /// Push a snapshot of the current content + cursor onto the undo stack.
1039    ///
1040    /// Truncates any redo tail beyond `history_index`, appends the snapshot,
1041    /// and caps the stack at [`history_max`](Self::history_max) by dropping the
1042    /// oldest entry. `history_index` is left pointing one past the newest
1043    /// snapshot so the next `Ctrl+Z` returns to the just-pushed state.
1044    pub(crate) fn push_history(&mut self) {
1045        if self.history_max == 0 {
1046            return;
1047        }
1048        // Drop any redo tail — a fresh edit invalidates the redo branch.
1049        if self.history_index < self.history.len() {
1050            self.history.truncate(self.history_index);
1051        }
1052        self.history.push(TextareaSnapshot {
1053            lines: self.lines.clone(),
1054            cursor_row: self.cursor_row,
1055            cursor_col: self.cursor_col,
1056        });
1057        // Evict oldest when over the cap. `Vec::remove(0)` is O(n) but the
1058        // history cap is small (default 100) and this only runs at the cap
1059        // boundary, so the cost is bounded.
1060        while self.history.len() > self.history_max {
1061            self.history.remove(0);
1062        }
1063        self.history_index = self.history.len();
1064    }
1065
1066    /// Walk the undo index back one step and apply the snapshot.
1067    ///
1068    /// No-op when the history is empty or already at the start. Returns `true`
1069    /// when a snapshot was applied.
1070    pub(crate) fn undo(&mut self) -> bool {
1071        if self.history.is_empty() || self.history_index == 0 {
1072            return false;
1073        }
1074        // First Ctrl+Z after edits captures the current (unsaved) tip so the
1075        // user can redo back to it; subsequent presses walk down the stack.
1076        if self.history_index == self.history.len() {
1077            self.history.push(TextareaSnapshot {
1078                lines: self.lines.clone(),
1079                cursor_row: self.cursor_row,
1080                cursor_col: self.cursor_col,
1081            });
1082        }
1083        self.history_index -= 1;
1084        let snap = &self.history[self.history_index];
1085        self.lines = snap.lines.clone();
1086        self.cursor_row = snap.cursor_row;
1087        self.cursor_col = snap.cursor_col;
1088        true
1089    }
1090
1091    /// Walk the undo index forward one step and apply the snapshot.
1092    ///
1093    /// No-op when already at the redo tip. Returns `true` when a snapshot was
1094    /// applied.
1095    pub(crate) fn redo(&mut self) -> bool {
1096        if self.history_index + 1 >= self.history.len() {
1097            return false;
1098        }
1099        self.history_index += 1;
1100        let snap = &self.history[self.history_index];
1101        self.lines = snap.lines.clone();
1102        self.cursor_row = snap.cursor_row;
1103        self.cursor_col = snap.cursor_col;
1104        true
1105    }
1106}
1107
1108impl Default for TextareaState {
1109    fn default() -> Self {
1110        Self::new()
1111    }
1112}
1113
1114/// Named throbber preset for [`SpinnerState`].
1115///
1116/// Each variant maps to a fixed frame sequence (parity with the common
1117/// `cli-spinners` / `ratatui-throbber` sets). Construct a spinner from a preset
1118/// with [`SpinnerState::preset`], or use the matching named constructor such as
1119/// [`SpinnerState::moon`].
1120///
1121/// # Example
1122///
1123/// ```
1124/// # use slt::widgets::{SpinnerState, SpinnerPreset};
1125/// let s = SpinnerState::preset(SpinnerPreset::Arrow);
1126/// assert_eq!(s, SpinnerState::arrow());
1127/// ```
1128///
1129/// Available since `0.21.1`.
1130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1131pub enum SpinnerPreset {
1132    /// Braille dots: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`.
1133    Dots,
1134    /// ASCII line: `| / - \`.
1135    Line,
1136    /// Moon phases: `🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘`.
1137    Moon,
1138    /// Bouncing bar between brackets: `(●    )` … `(    ●)` and back.
1139    Bounce,
1140    /// Quarter-circle arc: `◜ ◠ ◝ ◞ ◡ ◟`.
1141    Circle,
1142    /// Travelling braille dot: `⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈`.
1143    Points,
1144    /// Half-circle arc: `◜ ◠ ◝ ◞ ◡ ◟`.
1145    Arc,
1146    /// Toggle pulse: `⊶ ⊷`.
1147    Toggle,
1148    /// Clockwise arrow: `← ↖ ↑ ↗ → ↘ ↓ ↙`.
1149    Arrow,
1150}
1151
1152/// State for an animated spinner widget.
1153///
1154/// Create with a named constructor such as [`SpinnerState::dots`] or
1155/// [`SpinnerState::line`] (or from a [`SpinnerPreset`] via
1156/// [`SpinnerState::preset`]), then pass to `Context::spinner` each frame. The
1157/// frame advances automatically with the tick counter.
1158#[derive(Debug, Clone, PartialEq, Eq)]
1159pub struct SpinnerState {
1160    chars: &'static [char],
1161}
1162
1163static DOTS_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1164static LINE_CHARS: &[char] = &['|', '/', '-', '\\'];
1165static MOON_CHARS: &[char] = &['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
1166static BOUNCE_CHARS: &[char] = &['⠁', '⠂', '⠄', '⠂'];
1167static CIRCLE_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟'];
1168static POINTS_CHARS: &[char] = &['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'];
1169static ARC_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟'];
1170static TOGGLE_CHARS: &[char] = &['⊶', '⊷'];
1171static ARROW_CHARS: &[char] = &['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'];
1172
1173impl SpinnerState {
1174    /// Create a dots-style spinner using braille characters.
1175    ///
1176    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
1177    pub fn dots() -> Self {
1178        Self { chars: DOTS_CHARS }
1179    }
1180
1181    /// Create a line-style spinner using ASCII characters.
1182    ///
1183    /// Cycles through: `| / - \`
1184    pub fn line() -> Self {
1185        Self { chars: LINE_CHARS }
1186    }
1187
1188    /// Create a moon-phase spinner.
1189    ///
1190    /// Cycles through: `🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘`
1191    ///
1192    /// Available since `0.21.1`.
1193    pub fn moon() -> Self {
1194        Self { chars: MOON_CHARS }
1195    }
1196
1197    /// Create a bouncing single-dot spinner.
1198    ///
1199    /// Cycles through `⠁ ⠂ ⠄ ⠂`, giving a dot that rises and falls in place.
1200    ///
1201    /// Available since `0.21.1`.
1202    pub fn bounce() -> Self {
1203        Self {
1204            chars: BOUNCE_CHARS,
1205        }
1206    }
1207
1208    /// Create a quarter-circle arc spinner.
1209    ///
1210    /// Cycles through: `◜ ◠ ◝ ◞ ◡ ◟`
1211    ///
1212    /// Available since `0.21.1`.
1213    pub fn circle() -> Self {
1214        Self {
1215            chars: CIRCLE_CHARS,
1216        }
1217    }
1218
1219    /// Create a travelling braille-dot ("points") spinner.
1220    ///
1221    /// Cycles through: `⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈`
1222    ///
1223    /// Available since `0.21.1`.
1224    pub fn points() -> Self {
1225        Self {
1226            chars: POINTS_CHARS,
1227        }
1228    }
1229
1230    /// Create a half-circle arc spinner.
1231    ///
1232    /// Cycles through: `◜ ◠ ◝ ◞ ◡ ◟`
1233    ///
1234    /// Available since `0.21.1`.
1235    pub fn arc() -> Self {
1236        Self { chars: ARC_CHARS }
1237    }
1238
1239    /// Create a two-frame toggle/pulse spinner.
1240    ///
1241    /// Cycles through: `⊶ ⊷`
1242    ///
1243    /// Available since `0.21.1`.
1244    pub fn toggle() -> Self {
1245        Self {
1246            chars: TOGGLE_CHARS,
1247        }
1248    }
1249
1250    /// Create a rotating-arrow spinner.
1251    ///
1252    /// Cycles clockwise through: `← ↖ ↑ ↗ → ↘ ↓ ↙`
1253    ///
1254    /// Available since `0.21.1`.
1255    pub fn arrow() -> Self {
1256        Self { chars: ARROW_CHARS }
1257    }
1258
1259    /// Create a spinner from a named [`SpinnerPreset`].
1260    ///
1261    /// Equivalent to calling the matching named constructor.
1262    ///
1263    /// # Example
1264    ///
1265    /// ```
1266    /// # use slt::widgets::{SpinnerState, SpinnerPreset};
1267    /// let s = SpinnerState::preset(SpinnerPreset::Moon);
1268    /// assert_eq!(s, SpinnerState::moon());
1269    /// ```
1270    ///
1271    /// Available since `0.21.1`.
1272    pub fn preset(preset: SpinnerPreset) -> Self {
1273        match preset {
1274            SpinnerPreset::Dots => Self::dots(),
1275            SpinnerPreset::Line => Self::line(),
1276            SpinnerPreset::Moon => Self::moon(),
1277            SpinnerPreset::Bounce => Self::bounce(),
1278            SpinnerPreset::Circle => Self::circle(),
1279            SpinnerPreset::Points => Self::points(),
1280            SpinnerPreset::Arc => Self::arc(),
1281            SpinnerPreset::Toggle => Self::toggle(),
1282            SpinnerPreset::Arrow => Self::arrow(),
1283        }
1284    }
1285
1286    /// Number of distinct frames in this spinner's cycle.
1287    ///
1288    /// Useful for tests and for detecting wrap-around.
1289    ///
1290    /// # Example
1291    ///
1292    /// ```
1293    /// # use slt::widgets::SpinnerState;
1294    /// assert_eq!(SpinnerState::line().frame_count(), 4);
1295    /// ```
1296    ///
1297    /// Available since `0.21.1`.
1298    pub fn frame_count(&self) -> usize {
1299        self.chars.len()
1300    }
1301
1302    /// Return the spinner character for the given tick.
1303    pub fn frame(&self, tick: u64) -> char {
1304        if self.chars.is_empty() {
1305            return ' ';
1306        }
1307        self.chars[tick as usize % self.chars.len()]
1308    }
1309}
1310
1311impl Default for SpinnerState {
1312    fn default() -> Self {
1313        Self::dots()
1314    }
1315}
1316
1317/// State for a numeric stepper field (clamp + step, integer or float).
1318///
1319/// A numeric stepper renders the value as an editable field with `▾`/`▴`
1320/// affordances. When focused it adjusts via Up/Down (or `k`/`j`) and the scroll
1321/// wheel, or the user can type a value directly and press `Enter` to commit it.
1322/// The committed [`value`](NumberInputState::value) is always clamped into
1323/// `[min, max]` (and rounded to a whole number in integer mode).
1324///
1325/// Create with [`NumberInputState::new`] (float) or
1326/// [`NumberInputState::integer`], then pass to
1327/// [`Context::number_input`](crate::Context::number_input) each frame.
1328///
1329/// # Example
1330///
1331/// ```no_run
1332/// # use slt::widgets::NumberInputState;
1333/// # slt::run(|ui: &mut slt::Context| {
1334/// let mut qty = NumberInputState::integer(3, 0, 10).step(1.0);
1335/// let r = ui.number_input(&mut qty);
1336/// if r.changed {
1337///     // qty.value was adjusted this frame
1338/// }
1339/// # });
1340/// ```
1341///
1342/// Available since `0.21.0`.
1343#[derive(Debug, Clone)]
1344pub struct NumberInputState {
1345    /// Committed numeric value, always within `[min, max]`.
1346    pub value: f64,
1347    /// Inclusive lower bound.
1348    pub min: f64,
1349    /// Inclusive upper bound.
1350    pub max: f64,
1351    /// Increment applied per Up/Down/scroll tick.
1352    pub step: f64,
1353    /// When true, the value is whole-number only and rendered without a decimal point.
1354    pub integer: bool,
1355    /// In-progress typed text; `Some` while the user is editing the field.
1356    pub editing: Option<String>,
1357    /// Last parse failure from `Enter` on an invalid buffer, if any.
1358    pub parse_error: Option<String>,
1359}
1360
1361impl NumberInputState {
1362    /// Float stepper with the given starting value and inclusive range.
1363    ///
1364    /// `value` is clamped into `[min, max]` immediately. If `min > max` the two
1365    /// bounds are swapped so the range is always well-formed.
1366    ///
1367    /// # Example
1368    ///
1369    /// ```
1370    /// # use slt::widgets::NumberInputState;
1371    /// let s = NumberInputState::new(1.5, 0.0, 10.0);
1372    /// assert_eq!(s.value, 1.5);
1373    /// assert!(!s.integer);
1374    /// ```
1375    pub fn new(value: f64, min: f64, max: f64) -> Self {
1376        let (min, max) = if min <= max { (min, max) } else { (max, min) };
1377        Self {
1378            value: value.clamp(min, max),
1379            min,
1380            max,
1381            step: 1.0,
1382            integer: false,
1383            editing: None,
1384            parse_error: None,
1385        }
1386    }
1387
1388    /// Integer stepper (rounds value, renders without a decimal point).
1389    ///
1390    /// Convenience constructor that sets `integer = true` and a default step of
1391    /// `1.0`. `value` is clamped into `[min, max]`.
1392    ///
1393    /// # Example
1394    ///
1395    /// ```
1396    /// # use slt::widgets::NumberInputState;
1397    /// let s = NumberInputState::integer(42, 0, 100);
1398    /// assert_eq!(s.value, 42.0);
1399    /// assert!(s.integer);
1400    /// ```
1401    pub fn integer(value: i64, min: i64, max: i64) -> Self {
1402        let mut s = Self::new(value as f64, min as f64, max as f64);
1403        s.integer = true;
1404        s
1405    }
1406
1407    /// Set the per-tick increment (consumes self, builder style).
1408    ///
1409    /// Negative or zero steps are coerced to `0.0` (no adjustment).
1410    ///
1411    /// # Example
1412    ///
1413    /// ```
1414    /// # use slt::widgets::NumberInputState;
1415    /// let s = NumberInputState::new(0.0, 0.0, 1.0).step(0.1);
1416    /// assert!((s.step - 0.1).abs() < f64::EPSILON);
1417    /// ```
1418    pub fn step(mut self, step: f64) -> Self {
1419        self.step = step.max(0.0);
1420        self
1421    }
1422
1423    /// Clamp `value` into `[min, max]` (and round if `integer`).
1424    ///
1425    /// Used internally after every adjustment and typed commit, and exposed so
1426    /// callers that mutate [`value`](NumberInputState::value) directly can
1427    /// re-normalize it.
1428    ///
1429    /// # Example
1430    ///
1431    /// ```
1432    /// # use slt::widgets::NumberInputState;
1433    /// let mut s = NumberInputState::integer(0, 0, 10);
1434    /// s.value = 99.0;
1435    /// assert_eq!(s.clamped(), 10.0);
1436    /// s.value = 3.7;
1437    /// assert_eq!(s.clamped(), 4.0);
1438    /// ```
1439    pub fn clamped(&self) -> f64 {
1440        let v = self.value.clamp(self.min, self.max);
1441        if self.integer {
1442            v.round()
1443        } else {
1444            v
1445        }
1446    }
1447}
1448
1449impl Default for NumberInputState {
1450    fn default() -> Self {
1451        Self::new(0.0, 0.0, 100.0)
1452    }
1453}
1454
1455#[cfg(test)]
1456mod spinner_tests {
1457    use super::{SpinnerPreset, SpinnerState};
1458
1459    /// Collect one full cycle of frames for a spinner.
1460    fn cycle(s: &SpinnerState) -> Vec<char> {
1461        (0..s.frame_count() as u64).map(|t| s.frame(t)).collect()
1462    }
1463
1464    #[test]
1465    fn existing_presets_unchanged() {
1466        // dots() and line() must keep their historic sequences.
1467        assert_eq!(
1468            cycle(&SpinnerState::dots()),
1469            vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1470        );
1471        assert_eq!(cycle(&SpinnerState::line()), vec!['|', '/', '-', '\\']);
1472        // Default stays dots().
1473        assert_eq!(SpinnerState::default(), SpinnerState::dots());
1474    }
1475
1476    #[test]
1477    fn new_presets_have_expected_lengths() {
1478        assert_eq!(SpinnerState::dots().frame_count(), 10);
1479        assert_eq!(SpinnerState::line().frame_count(), 4);
1480        assert_eq!(SpinnerState::moon().frame_count(), 8);
1481        assert_eq!(SpinnerState::bounce().frame_count(), 4);
1482        assert_eq!(SpinnerState::circle().frame_count(), 6);
1483        assert_eq!(SpinnerState::points().frame_count(), 8);
1484        assert_eq!(SpinnerState::arc().frame_count(), 6);
1485        assert_eq!(SpinnerState::toggle().frame_count(), 2);
1486        assert_eq!(SpinnerState::arrow().frame_count(), 8);
1487    }
1488
1489    #[test]
1490    fn new_presets_yield_expected_sequences() {
1491        assert_eq!(
1492            cycle(&SpinnerState::moon()),
1493            vec!['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']
1494        );
1495        assert_eq!(cycle(&SpinnerState::bounce()), vec!['⠁', '⠂', '⠄', '⠂']);
1496        assert_eq!(
1497            cycle(&SpinnerState::circle()),
1498            vec!['◜', '◠', '◝', '◞', '◡', '◟']
1499        );
1500        assert_eq!(
1501            cycle(&SpinnerState::points()),
1502            vec!['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈']
1503        );
1504        assert_eq!(
1505            cycle(&SpinnerState::arc()),
1506            vec!['◜', '◠', '◝', '◞', '◡', '◟']
1507        );
1508        assert_eq!(cycle(&SpinnerState::toggle()), vec!['⊶', '⊷']);
1509        assert_eq!(
1510            cycle(&SpinnerState::arrow()),
1511            vec!['←', '↖', '↑', '↗', '→', '↘', '↓', '↙']
1512        );
1513    }
1514
1515    #[test]
1516    fn frame_cycles_modulo_length() {
1517        let s = SpinnerState::arrow();
1518        let n = s.frame_count() as u64;
1519        // Tick 0 and one full revolution later yield the same frame.
1520        assert_eq!(s.frame(0), s.frame(n));
1521        assert_eq!(s.frame(1), s.frame(n + 1));
1522        // Wrap-around at the boundary.
1523        assert_eq!(s.frame(n - 1), '↙');
1524        assert_eq!(s.frame(n), '←');
1525    }
1526
1527    #[test]
1528    fn frame_advances_through_sequence() {
1529        let s = SpinnerState::toggle();
1530        assert_eq!(s.frame(0), '⊶');
1531        assert_eq!(s.frame(1), '⊷');
1532        assert_eq!(s.frame(2), '⊶');
1533        assert_eq!(s.frame(3), '⊷');
1534    }
1535
1536    #[test]
1537    fn preset_matches_named_constructor() {
1538        let cases = [
1539            (SpinnerPreset::Dots, SpinnerState::dots()),
1540            (SpinnerPreset::Line, SpinnerState::line()),
1541            (SpinnerPreset::Moon, SpinnerState::moon()),
1542            (SpinnerPreset::Bounce, SpinnerState::bounce()),
1543            (SpinnerPreset::Circle, SpinnerState::circle()),
1544            (SpinnerPreset::Points, SpinnerState::points()),
1545            (SpinnerPreset::Arc, SpinnerState::arc()),
1546            (SpinnerPreset::Toggle, SpinnerState::toggle()),
1547            (SpinnerPreset::Arrow, SpinnerState::arrow()),
1548        ];
1549        for (preset, expected) in cases {
1550            assert_eq!(SpinnerState::preset(preset), expected);
1551        }
1552    }
1553
1554    #[test]
1555    fn frame_handles_large_tick_without_panicking() {
1556        // Edge case: very large tick must wrap, not overflow/panic.
1557        let s = SpinnerState::moon();
1558        let n = s.frame_count() as u64;
1559        assert_eq!(s.frame(u64::MAX), s.frame(u64::MAX % n));
1560    }
1561}