crustkit 0.2.0

Small Ratatui and Crossterm primitives for terminal applications.
Documentation
use std::{
    borrow::Cow,
    time::{SystemTime, UNIX_EPOCH},
};

use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, BorderType, Paragraph},
};

#[cfg(feature = "tachyonfx")]
use crate::effects::ComponentEffect;

#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(not(feature = "tachyonfx"), derive(Eq))]
pub struct TextInput {
    title: String,
    label: String,
    value: String,
    placeholder: String,
    focused: bool,
    help: Option<String>,
    cursor: bool,
    #[cfg(feature = "tachyonfx")]
    effect: Option<ComponentEffect>,
}

impl TextInput {
    pub fn new(title: impl Into<String>, label: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            label: label.into(),
            value: String::new(),
            placeholder: String::new(),
            focused: false,
            help: None,
            cursor: true,
            #[cfg(feature = "tachyonfx")]
            effect: None,
        }
    }

    pub fn value(mut self, value: impl Into<String>) -> Self {
        self.value = value.into();
        self
    }

    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
        self.placeholder = placeholder.into();
        self
    }

    pub fn focused(mut self, focused: bool) -> Self {
        self.focused = focused;
        self
    }

    pub fn cursor(mut self, enabled: bool) -> Self {
        self.cursor = enabled;
        self
    }

    pub fn help(mut self, help: impl Into<String>) -> Self {
        self.help = Some(help.into());
        self
    }

    #[cfg(feature = "tachyonfx")]
    pub fn effect(mut self, effect: ComponentEffect) -> Self {
        self.effect = Some(effect);
        self
    }

    #[cfg(feature = "tachyonfx")]
    pub const fn component_effect(&self) -> Option<ComponentEffect> {
        self.effect
    }

    pub fn widget(self) -> Paragraph<'static> {
        let border_style = if self.focused {
            Style::new().fg(Color::Cyan)
        } else {
            Style::new().fg(Color::DarkGray)
        };
        let input_style = if self.focused {
            Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
        } else {
            Style::new()
        };
        let value_spans = input_value_spans(
            self.value,
            self.placeholder,
            input_style,
            Style::new().fg(Color::DarkGray),
            input_style.add_modifier(Modifier::REVERSED),
            self.focused && self.cursor,
        );

        let mut input_spans = Vec::with_capacity(value_spans.len() + 2);
        input_spans.push(Span::styled(self.label, Style::new().bold()));
        input_spans.push(Span::raw(" "));
        input_spans.extend(value_spans);

        let mut lines = Vec::with_capacity(if self.help.is_some() { 3 } else { 2 });
        lines.push(Line::from(input_spans));
        lines.push(Line::from(""));

        if let Some(help) = self.help {
            lines.push(Line::from(Span::styled(
                help,
                Style::new().fg(Color::DarkGray),
            )));
        }

        Paragraph::new(lines).block(
            Block::bordered()
                .title(self.title)
                .border_type(BorderType::Rounded)
                .border_style(border_style),
        )
    }
}

pub fn input_value_spans<'a>(
    value: impl Into<Cow<'a, str>>,
    placeholder: impl Into<Cow<'a, str>>,
    input_style: Style,
    placeholder_style: Style,
    cursor_style: Style,
    show_cursor: bool,
) -> Vec<Span<'a>> {
    let value = value.into();
    let placeholder = placeholder.into();
    let cursor = if show_cursor && cursor_blink_on() {
        Span::styled(" ", cursor_style)
    } else if show_cursor {
        Span::raw(" ")
    } else {
        Span::raw("")
    };

    if value.is_empty() {
        vec![cursor, Span::styled(placeholder, placeholder_style)]
    } else {
        vec![Span::styled(value, input_style), cursor]
    }
}

fn cursor_blink_on() -> bool {
    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or_default();
    (millis / 500).is_multiple_of(2)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builds_input_with_value() {
        let input = TextInput::new("Search", "Show:")
            .value("The Office")
            .focused(true);

        assert_eq!(input.value, "The Office");
        assert!(input.focused);
    }

    #[test]
    fn cursor_can_be_disabled() {
        let input = TextInput::new("Search", "Show:").cursor(false);

        assert!(!input.cursor);
    }
}