#[derive(Debug, Clone, Default)]
pub struct StaticOutput {
lines: Vec<String>,
new_lines: Vec<String>,
}
impl StaticOutput {
pub fn new() -> Self {
Self::default()
}
pub fn println(&mut self, line: impl Into<String>) {
let line = line.into();
self.lines.push(line.clone());
self.new_lines.push(line);
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn drain_new(&mut self) -> Vec<String> {
std::mem::take(&mut self.new_lines)
}
pub fn clear(&mut self) {
self.lines.clear();
self.new_lines.clear();
}
}
pub struct TextInputState {
pub value: String,
pub cursor: usize,
pub placeholder: String,
pub max_length: Option<usize>,
pub validation_error: Option<String>,
pub masked: bool,
pub suggestions: Vec<String>,
pub suggestion_index: usize,
pub show_suggestions: bool,
validators: Vec<TextInputValidator>,
validation_errors: Vec<String>,
}
impl std::fmt::Debug for TextInputState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextInputState")
.field("value", &self.value)
.field("cursor", &self.cursor)
.field("placeholder", &self.placeholder)
.field("max_length", &self.max_length)
.field("validation_error", &self.validation_error)
.field("masked", &self.masked)
.field("suggestions", &self.suggestions)
.field("suggestion_index", &self.suggestion_index)
.field("show_suggestions", &self.show_suggestions)
.field("validators_len", &self.validators.len())
.field("validation_errors", &self.validation_errors)
.finish()
}
}
impl Clone for TextInputState {
fn clone(&self) -> Self {
Self {
value: self.value.clone(),
cursor: self.cursor,
placeholder: self.placeholder.clone(),
max_length: self.max_length,
validation_error: self.validation_error.clone(),
masked: self.masked,
suggestions: self.suggestions.clone(),
suggestion_index: self.suggestion_index,
show_suggestions: self.show_suggestions,
validators: Vec::new(),
validation_errors: self.validation_errors.clone(),
}
}
}
impl TextInputState {
pub fn new() -> Self {
Self {
value: String::new(),
cursor: 0,
placeholder: String::new(),
max_length: None,
validation_error: None,
masked: false,
suggestions: Vec::new(),
suggestion_index: 0,
show_suggestions: false,
validators: Vec::new(),
validation_errors: Vec::new(),
}
}
pub fn with_placeholder(p: impl Into<String>) -> Self {
Self {
placeholder: p.into(),
..Self::new()
}
}
pub fn max_length(mut self, len: usize) -> Self {
self.max_length = Some(len);
self
}
pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
self.validation_error = validator(&self.value).err();
}
pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
self.validators.push(Box::new(f));
}
pub fn run_validators(&mut self) {
self.validation_errors.clear();
for validator in &self.validators {
if let Err(err) = validator(&self.value) {
self.validation_errors.push(err);
}
}
self.validation_error = self.validation_errors.first().cloned();
}
pub fn errors(&self) -> &[String] {
&self.validation_errors
}
pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
self.suggestions = suggestions;
self.suggestion_index = 0;
self.show_suggestions = !self.suggestions.is_empty();
}
pub fn matched_suggestions(&self) -> Vec<&str> {
if self.value.is_empty() {
return Vec::new();
}
let lower = self.value.to_lowercase();
self.suggestions
.iter()
.filter(|s| s.to_lowercase().starts_with(&lower))
.map(|s| s.as_str())
.collect()
}
}
impl Default for TextInputState {
fn default() -> Self {
Self::new()
}
}
pub struct Validator(TextInputValidator);
impl Validator {
pub fn new(f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
Self(Box::new(f))
}
pub fn run(&self, value: &str) -> Result<(), String> {
(self.0)(value)
}
}
impl std::fmt::Debug for Validator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Validator(<fn>)")
}
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub struct AsyncValidation {
rx: tokio::sync::oneshot::Receiver<Result<(), String>>,
}
#[cfg(feature = "async")]
impl std::fmt::Debug for AsyncValidation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AsyncValidation(<pending>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ValidateTrigger {
OnChange,
#[default]
OnBlur,
Manual,
}
#[derive(Debug, Default)]
pub struct FormField {
pub label: String,
pub input: TextInputState,
pub error: Option<String>,
pub trigger: ValidateTrigger,
validators: Vec<Validator>,
was_focused: bool,
#[cfg(feature = "async")]
pending: Option<AsyncValidation>,
}
impl FormField {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
input: TextInputState::new(),
error: None,
trigger: ValidateTrigger::default(),
validators: Vec::new(),
was_focused: false,
#[cfg(feature = "async")]
pending: None,
}
}
pub fn placeholder(mut self, p: impl Into<String>) -> Self {
self.input.placeholder = p.into();
self
}
pub fn validate(mut self, f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
self.validators.push(Validator::new(f));
self
}
pub fn on_change(mut self) -> Self {
self.trigger = ValidateTrigger::OnChange;
self
}
pub fn on_blur(mut self) -> Self {
self.trigger = ValidateTrigger::OnBlur;
self
}
pub fn manual(mut self) -> Self {
self.trigger = ValidateTrigger::Manual;
self
}
pub fn validator_count(&self) -> usize {
self.validators.len()
}
pub fn run_validators(&mut self) -> bool {
self.error = self
.validators
.iter()
.find_map(|v| v.run(&self.input.value).err());
self.error.is_none()
}
pub(crate) fn observe_focus(&mut self, focused: bool) -> bool {
let lost = self.was_focused && !focused;
self.was_focused = focused;
lost
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub fn validate_async<F>(&mut self, future: F)
where
F: std::future::Future<Output = Result<(), String>> + Send + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = future.await;
let _ = tx.send(result);
});
self.pending = Some(AsyncValidation { rx });
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub fn is_validating(&self) -> bool {
self.pending.is_some()
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub fn poll_async(&mut self) -> bool {
use tokio::sync::oneshot::error::TryRecvError;
let Some(pending) = self.pending.as_mut() else {
return false;
};
match pending.rx.try_recv() {
Ok(result) => {
self.error = result.err();
self.pending = None;
true
}
Err(TryRecvError::Empty) => false,
Err(TryRecvError::Closed) => {
self.pending = None;
true
}
}
}
}
#[derive(Debug)]
pub struct FormState {
pub fields: Vec<FormField>,
pub submitted: bool,
}
impl FormState {
pub fn new() -> Self {
Self {
fields: Vec::new(),
submitted: false,
}
}
pub fn field(mut self, field: FormField) -> Self {
self.fields.push(field);
self
}
pub fn is_valid(&self) -> bool {
self.fields.iter().all(|f| f.error.is_none())
}
pub fn errors(&self) -> Vec<(usize, &str)> {
self.fields
.iter()
.enumerate()
.filter_map(|(i, f)| f.error.as_deref().map(|e| (i, e)))
.collect()
}
pub fn validate_all(&mut self) -> bool {
let mut ok = true;
for field in &mut self.fields {
ok &= field.run_validators();
}
ok
}
pub fn validate_with(
&mut self,
f: impl Fn(&FormState) -> Vec<(usize, String)>,
) -> bool {
let extra = f(self);
for (i, msg) in &extra {
if let Some(field) = self.fields.get_mut(*i) {
field.error = Some(msg.clone());
}
}
extra.is_empty()
}
#[deprecated(
since = "0.21.0",
note = "Attach validators per-field via FormField::validate and call validate_all(); positional slices misalign silently."
)]
pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
let mut all_valid = true;
for (i, field) in self.fields.iter_mut().enumerate() {
if let Some(validator) = validators.get(i) {
match validator(&field.input.value) {
Ok(()) => field.error = None,
Err(msg) => {
field.error = Some(msg);
all_valid = false;
}
}
}
}
all_valid
}
pub fn value(&self, index: usize) -> &str {
self.fields
.get(index)
.map(|f| f.input.value.as_str())
.unwrap_or("")
}
}
impl Default for FormState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ToastState {
pub messages: Vec<ToastMessage>,
}
#[derive(Debug, Clone)]
pub struct ToastMessage {
pub text: String,
pub level: ToastLevel,
pub created_tick: u64,
pub duration_ticks: u64,
}
impl Default for ToastMessage {
fn default() -> Self {
Self {
text: String::new(),
level: ToastLevel::Info,
created_tick: 0,
duration_ticks: 30,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastLevel {
Info,
Success,
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertLevel {
Info,
Success,
Warning,
Error,
}
impl ToastState {
pub fn new() -> Self {
Self {
messages: Vec::new(),
}
}
pub fn info(&mut self, text: impl Into<String>, tick: u64) {
self.push(text, ToastLevel::Info, tick, 30);
}
pub fn success(&mut self, text: impl Into<String>, tick: u64) {
self.push(text, ToastLevel::Success, tick, 30);
}
pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
self.push(text, ToastLevel::Warning, tick, 50);
}
pub fn error(&mut self, text: impl Into<String>, tick: u64) {
self.push(text, ToastLevel::Error, tick, 80);
}
pub fn push(
&mut self,
text: impl Into<String>,
level: ToastLevel,
tick: u64,
duration_ticks: u64,
) {
self.messages.push(ToastMessage {
text: text.into(),
level,
created_tick: tick,
duration_ticks,
});
}
pub fn cleanup(&mut self, current_tick: u64) {
self.messages.retain(|message| {
current_tick < message.created_tick.saturating_add(message.duration_ticks)
});
}
}
impl Default for ToastState {
fn default() -> Self {
Self::new()
}
}
pub(crate) const DEFAULT_TEXTAREA_HISTORY_MAX: usize = 100;
#[derive(Debug, Clone)]
pub(crate) struct TextareaSnapshot {
pub(crate) lines: Vec<String>,
pub(crate) cursor_row: usize,
pub(crate) cursor_col: usize,
}
#[derive(Debug, Clone)]
pub struct TextareaState {
pub lines: Vec<String>,
pub cursor_row: usize,
pub cursor_col: usize,
pub max_length: Option<usize>,
pub wrap_width: Option<u32>,
pub scroll_offset: usize,
pub(crate) history: Vec<TextareaSnapshot>,
pub(crate) history_index: usize,
pub(crate) history_max: usize,
pub(crate) last_was_char_insert: bool,
}
impl TextareaState {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
max_length: None,
wrap_width: None,
scroll_offset: 0,
history: Vec::new(),
history_index: 0,
history_max: DEFAULT_TEXTAREA_HISTORY_MAX,
last_was_char_insert: false,
}
}
pub fn value(&self) -> String {
self.lines.join("\n")
}
pub fn set_value(&mut self, text: impl Into<String>) {
let value = text.into();
self.lines = value.split('\n').map(str::to_string).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.cursor_row = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
self.history.clear();
self.history_index = 0;
self.last_was_char_insert = false;
}
pub fn max_length(mut self, len: usize) -> Self {
self.max_length = Some(len);
self
}
pub fn word_wrap(mut self, width: u32) -> Self {
self.wrap_width = Some(width);
self
}
pub fn history_max(mut self, cap: usize) -> Self {
self.history_max = cap;
self
}
pub fn history_len(&self) -> usize {
self.history.len()
}
pub fn history_cap(&self) -> usize {
self.history_max
}
pub(crate) fn push_history(&mut self) {
if self.history_max == 0 {
return;
}
if self.history_index < self.history.len() {
self.history.truncate(self.history_index);
}
self.history.push(TextareaSnapshot {
lines: self.lines.clone(),
cursor_row: self.cursor_row,
cursor_col: self.cursor_col,
});
while self.history.len() > self.history_max {
self.history.remove(0);
}
self.history_index = self.history.len();
}
pub(crate) fn undo(&mut self) -> bool {
if self.history.is_empty() || self.history_index == 0 {
return false;
}
if self.history_index == self.history.len() {
self.history.push(TextareaSnapshot {
lines: self.lines.clone(),
cursor_row: self.cursor_row,
cursor_col: self.cursor_col,
});
}
self.history_index -= 1;
let snap = &self.history[self.history_index];
self.lines = snap.lines.clone();
self.cursor_row = snap.cursor_row;
self.cursor_col = snap.cursor_col;
true
}
pub(crate) fn redo(&mut self) -> bool {
if self.history_index + 1 >= self.history.len() {
return false;
}
self.history_index += 1;
let snap = &self.history[self.history_index];
self.lines = snap.lines.clone();
self.cursor_row = snap.cursor_row;
self.cursor_col = snap.cursor_col;
true
}
}
impl Default for TextareaState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpinnerPreset {
Dots,
Line,
Moon,
Bounce,
Circle,
Points,
Arc,
Toggle,
Arrow,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpinnerState {
chars: &'static [char],
}
static DOTS_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
static LINE_CHARS: &[char] = &['|', '/', '-', '\\'];
static MOON_CHARS: &[char] = &['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
static BOUNCE_CHARS: &[char] = &['⠁', '⠂', '⠄', '⠂'];
static CIRCLE_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟'];
static POINTS_CHARS: &[char] = &['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'];
static ARC_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟'];
static TOGGLE_CHARS: &[char] = &['⊶', '⊷'];
static ARROW_CHARS: &[char] = &['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'];
impl SpinnerState {
pub fn dots() -> Self {
Self { chars: DOTS_CHARS }
}
pub fn line() -> Self {
Self { chars: LINE_CHARS }
}
pub fn moon() -> Self {
Self { chars: MOON_CHARS }
}
pub fn bounce() -> Self {
Self {
chars: BOUNCE_CHARS,
}
}
pub fn circle() -> Self {
Self {
chars: CIRCLE_CHARS,
}
}
pub fn points() -> Self {
Self {
chars: POINTS_CHARS,
}
}
pub fn arc() -> Self {
Self { chars: ARC_CHARS }
}
pub fn toggle() -> Self {
Self {
chars: TOGGLE_CHARS,
}
}
pub fn arrow() -> Self {
Self { chars: ARROW_CHARS }
}
pub fn preset(preset: SpinnerPreset) -> Self {
match preset {
SpinnerPreset::Dots => Self::dots(),
SpinnerPreset::Line => Self::line(),
SpinnerPreset::Moon => Self::moon(),
SpinnerPreset::Bounce => Self::bounce(),
SpinnerPreset::Circle => Self::circle(),
SpinnerPreset::Points => Self::points(),
SpinnerPreset::Arc => Self::arc(),
SpinnerPreset::Toggle => Self::toggle(),
SpinnerPreset::Arrow => Self::arrow(),
}
}
pub fn frame_count(&self) -> usize {
self.chars.len()
}
pub fn frame(&self, tick: u64) -> char {
if self.chars.is_empty() {
return ' ';
}
self.chars[tick as usize % self.chars.len()]
}
}
impl Default for SpinnerState {
fn default() -> Self {
Self::dots()
}
}
#[derive(Debug, Clone)]
pub struct NumberInputState {
pub value: f64,
pub min: f64,
pub max: f64,
pub step: f64,
pub integer: bool,
pub editing: Option<String>,
pub parse_error: Option<String>,
}
impl NumberInputState {
pub fn new(value: f64, min: f64, max: f64) -> Self {
let (min, max) = if min <= max { (min, max) } else { (max, min) };
Self {
value: value.clamp(min, max),
min,
max,
step: 1.0,
integer: false,
editing: None,
parse_error: None,
}
}
pub fn integer(value: i64, min: i64, max: i64) -> Self {
let mut s = Self::new(value as f64, min as f64, max as f64);
s.integer = true;
s
}
pub fn step(mut self, step: f64) -> Self {
self.step = step.max(0.0);
self
}
pub fn clamped(&self) -> f64 {
let v = self.value.clamp(self.min, self.max);
if self.integer {
v.round()
} else {
v
}
}
}
impl Default for NumberInputState {
fn default() -> Self {
Self::new(0.0, 0.0, 100.0)
}
}
#[cfg(test)]
mod spinner_tests {
use super::{SpinnerPreset, SpinnerState};
fn cycle(s: &SpinnerState) -> Vec<char> {
(0..s.frame_count() as u64).map(|t| s.frame(t)).collect()
}
#[test]
fn existing_presets_unchanged() {
assert_eq!(
cycle(&SpinnerState::dots()),
vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
);
assert_eq!(cycle(&SpinnerState::line()), vec!['|', '/', '-', '\\']);
assert_eq!(SpinnerState::default(), SpinnerState::dots());
}
#[test]
fn new_presets_have_expected_lengths() {
assert_eq!(SpinnerState::dots().frame_count(), 10);
assert_eq!(SpinnerState::line().frame_count(), 4);
assert_eq!(SpinnerState::moon().frame_count(), 8);
assert_eq!(SpinnerState::bounce().frame_count(), 4);
assert_eq!(SpinnerState::circle().frame_count(), 6);
assert_eq!(SpinnerState::points().frame_count(), 8);
assert_eq!(SpinnerState::arc().frame_count(), 6);
assert_eq!(SpinnerState::toggle().frame_count(), 2);
assert_eq!(SpinnerState::arrow().frame_count(), 8);
}
#[test]
fn new_presets_yield_expected_sequences() {
assert_eq!(
cycle(&SpinnerState::moon()),
vec!['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']
);
assert_eq!(cycle(&SpinnerState::bounce()), vec!['⠁', '⠂', '⠄', '⠂']);
assert_eq!(
cycle(&SpinnerState::circle()),
vec!['◜', '◠', '◝', '◞', '◡', '◟']
);
assert_eq!(
cycle(&SpinnerState::points()),
vec!['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈']
);
assert_eq!(
cycle(&SpinnerState::arc()),
vec!['◜', '◠', '◝', '◞', '◡', '◟']
);
assert_eq!(cycle(&SpinnerState::toggle()), vec!['⊶', '⊷']);
assert_eq!(
cycle(&SpinnerState::arrow()),
vec!['←', '↖', '↑', '↗', '→', '↘', '↓', '↙']
);
}
#[test]
fn frame_cycles_modulo_length() {
let s = SpinnerState::arrow();
let n = s.frame_count() as u64;
assert_eq!(s.frame(0), s.frame(n));
assert_eq!(s.frame(1), s.frame(n + 1));
assert_eq!(s.frame(n - 1), '↙');
assert_eq!(s.frame(n), '←');
}
#[test]
fn frame_advances_through_sequence() {
let s = SpinnerState::toggle();
assert_eq!(s.frame(0), '⊶');
assert_eq!(s.frame(1), '⊷');
assert_eq!(s.frame(2), '⊶');
assert_eq!(s.frame(3), '⊷');
}
#[test]
fn preset_matches_named_constructor() {
let cases = [
(SpinnerPreset::Dots, SpinnerState::dots()),
(SpinnerPreset::Line, SpinnerState::line()),
(SpinnerPreset::Moon, SpinnerState::moon()),
(SpinnerPreset::Bounce, SpinnerState::bounce()),
(SpinnerPreset::Circle, SpinnerState::circle()),
(SpinnerPreset::Points, SpinnerState::points()),
(SpinnerPreset::Arc, SpinnerState::arc()),
(SpinnerPreset::Toggle, SpinnerState::toggle()),
(SpinnerPreset::Arrow, SpinnerState::arrow()),
];
for (preset, expected) in cases {
assert_eq!(SpinnerState::preset(preset), expected);
}
}
#[test]
fn frame_handles_large_tick_without_panicking() {
let s = SpinnerState::moon();
let n = s.frame_count() as u64;
assert_eq!(s.frame(u64::MAX), s.frame(u64::MAX % n));
}
}