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}