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