Skip to main content

slt/widgets/
input.rs

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