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}