ttypo 0.1.4

Terminal-based typing test.
use ratatui::{
    style::{Color, Modifier, Style},
    widgets::BorderType,
};
use serde::{
    Deserialize,
    de::{self, IntoDeserializer},
};

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
    pub default_language: String,
    pub theme: Theme,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            default_language: "english200".into(),
            theme: Theme::default(),
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Theme {
    #[serde(deserialize_with = "deserialize_style")]
    pub default: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub title: Style,

    // test widget
    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_border: Style,

    #[serde(deserialize_with = "deserialize_border_type")]
    pub border_type: BorderType,

    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_correct: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_incorrect: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_untyped: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_current_correct: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_current_incorrect: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_current_untyped: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_cursor: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub prompt_skipped: Style,

    // status bar (during test)
    #[serde(deserialize_with = "deserialize_style")]
    pub status_wpm: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub status_timer: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub status_progress: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub status_progress_filled: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub status_progress_empty: Style,

    // results widget
    #[serde(deserialize_with = "deserialize_style")]
    pub results_overview: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_overview_border: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub results_worst_keys: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_worst_keys_border: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub results_missed_words: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_missed_words_border: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub results_chart: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_chart_mistakes: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_chart_x: Style,
    #[serde(deserialize_with = "deserialize_style")]
    pub results_chart_y: Style,

    #[serde(deserialize_with = "deserialize_style")]
    pub results_restart_prompt: Style,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            default: Style::default(),

            title: Style::default()
                .fg(Color::Rgb(230, 230, 230))
                .add_modifier(Modifier::BOLD),

            prompt_border: Style::default().fg(Color::Rgb(80, 80, 120)),

            border_type: BorderType::Rounded,

            prompt_correct: Style::default().fg(Color::Rgb(100, 120, 100)),
            prompt_incorrect: Style::default().fg(Color::Rgb(200, 80, 80)),
            prompt_untyped: Style::default().fg(Color::Rgb(200, 200, 200)),

            prompt_current_correct: Style::default()
                .fg(Color::Rgb(120, 180, 120))
                .add_modifier(Modifier::BOLD),
            prompt_current_incorrect: Style::default()
                .fg(Color::Rgb(200, 100, 80))
                .add_modifier(Modifier::BOLD),
            prompt_current_untyped: Style::default()
                .fg(Color::Rgb(200, 200, 220))
                .add_modifier(Modifier::BOLD),

            prompt_cursor: Style::default()
                .add_modifier(Modifier::REVERSED)
                .add_modifier(Modifier::BOLD),

            prompt_skipped: Style::default().fg(Color::Rgb(200, 180, 60)),

            status_wpm: Style::default()
                .fg(Color::Rgb(100, 200, 100))
                .add_modifier(Modifier::BOLD),
            status_timer: Style::default().fg(Color::Rgb(180, 180, 200)),
            status_progress: Style::default().fg(Color::Rgb(180, 180, 200)),
            status_progress_filled: Style::default().fg(Color::Rgb(100, 200, 100)),
            status_progress_empty: Style::default().fg(Color::Rgb(50, 50, 50)),

            results_overview: Style::default()
                .fg(Color::Rgb(100, 200, 100))
                .add_modifier(Modifier::BOLD),
            results_overview_border: Style::default().fg(Color::Rgb(80, 80, 120)),

            results_worst_keys: Style::default()
                .fg(Color::Rgb(220, 180, 60))
                .add_modifier(Modifier::BOLD),
            results_worst_keys_border: Style::default().fg(Color::Rgb(80, 80, 120)),

            results_missed_words: Style::default()
                .fg(Color::Rgb(230, 80, 80))
                .add_modifier(Modifier::BOLD),
            results_missed_words_border: Style::default().fg(Color::Rgb(80, 80, 120)),

            results_chart: Style::default().fg(Color::Rgb(80, 180, 220)),
            results_chart_mistakes: Style::default().fg(Color::Rgb(230, 80, 80)),
            results_chart_x: Style::default().fg(Color::Rgb(110, 110, 110)),
            results_chart_y: Style::default()
                .fg(Color::Rgb(110, 110, 110))
                .add_modifier(Modifier::BOLD),

            results_restart_prompt: Style::default()
                .fg(Color::Rgb(180, 180, 200))
                .add_modifier(Modifier::BOLD),
        }
    }
}

fn deserialize_style<'de, D>(deserializer: D) -> Result<Style, D::Error>
where
    D: de::Deserializer<'de>,
{
    struct StyleVisitor;
    impl de::Visitor<'_> for StyleVisitor {
        type Value = Style;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("a string describing a text style")
        }

        fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
            let (colors, modifiers) = value.split_once(';').unwrap_or((value, ""));
            let (fg, bg) = colors.split_once(':').unwrap_or((colors, "none"));

            let mut style = Style {
                fg: match fg {
                    "none" | "" => None,
                    _ => Some(deserialize_color(fg.into_deserializer())?),
                },
                bg: match bg {
                    "none" | "" => None,
                    _ => Some(deserialize_color(bg.into_deserializer())?),
                },
                ..Default::default()
            };

            for modifier in modifiers.split_terminator(';') {
                style = style.add_modifier(match modifier {
                    "bold" => Modifier::BOLD,
                    "crossed_out" => Modifier::CROSSED_OUT,
                    "dim" => Modifier::DIM,
                    "hidden" => Modifier::HIDDEN,
                    "italic" => Modifier::ITALIC,
                    "rapid_blink" => Modifier::RAPID_BLINK,
                    "slow_blink" => Modifier::SLOW_BLINK,
                    "reversed" => Modifier::REVERSED,
                    "underlined" => Modifier::UNDERLINED,
                    _ => {
                        return Err(E::invalid_value(
                            de::Unexpected::Str(modifier),
                            &"a style modifier",
                        ));
                    }
                });
            }

            Ok(style)
        }
    }

    deserializer.deserialize_str(StyleVisitor)
}

fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
    D: de::Deserializer<'de>,
{
    struct ColorVisitor;
    impl de::Visitor<'_> for ColorVisitor {
        type Value = Color;

        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            formatter.write_str("a color name or hexadecimal color code")
        }

        fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
            match value {
                "reset" => Ok(Color::Reset),
                "black" => Ok(Color::Black),
                "white" => Ok(Color::White),
                "red" => Ok(Color::Red),
                "green" => Ok(Color::Green),
                "yellow" => Ok(Color::Yellow),
                "blue" => Ok(Color::Blue),
                "magenta" => Ok(Color::Magenta),
                "cyan" => Ok(Color::Cyan),
                "gray" => Ok(Color::Gray),
                "darkgray" => Ok(Color::DarkGray),
                "lightred" => Ok(Color::LightRed),
                "lightgreen" => Ok(Color::LightGreen),
                "lightyellow" => Ok(Color::LightYellow),
                "lightblue" => Ok(Color::LightBlue),
                "lightmagenta" => Ok(Color::LightMagenta),
                "lightcyan" => Ok(Color::LightCyan),
                _ => {
                    if value.len() == 6 {
                        let parse_error = |_| E::custom("color code was not valid hexadecimal");

                        Ok(Color::Rgb(
                            u8::from_str_radix(&value[0..2], 16).map_err(parse_error)?,
                            u8::from_str_radix(&value[2..4], 16).map_err(parse_error)?,
                            u8::from_str_radix(&value[4..6], 16).map_err(parse_error)?,
                        ))
                    } else {
                        Err(E::invalid_value(
                            de::Unexpected::Str(value),
                            &"a color name or hexadecimal color code",
                        ))
                    }
                }
            }
        }
    }

    deserializer.deserialize_str(ColorVisitor)
}

fn deserialize_border_type<'de, D>(deserializer: D) -> Result<BorderType, D::Error>
where
    D: de::Deserializer<'de>,
{
    struct BorderTypeVisitor;
    impl de::Visitor<'_> for BorderTypeVisitor {
        type Value = BorderType;

        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            formatter.write_str("a border type")
        }

        fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
            match value {
                "plain" => Ok(BorderType::Plain),
                "rounded" => Ok(BorderType::Rounded),
                "double" => Ok(BorderType::Double),
                "thick" => Ok(BorderType::Thick),
                "quadrantinside" => Ok(BorderType::QuadrantInside),
                "quadrantoutside" => Ok(BorderType::QuadrantOutside),
                _ => Err(E::invalid_value(
                    de::Unexpected::Str(value),
                    &"a border type",
                )),
            }
        }
    }

    deserializer.deserialize_str(BorderTypeVisitor)
}

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

    #[test]
    fn deserializes_basic_colors() {
        fn color(string: &str) -> Color {
            deserialize_color(de::IntoDeserializer::<de::value::Error>::into_deserializer(
                string,
            ))
            .expect("failed to deserialize color")
        }

        assert_eq!(color("black"), Color::Black);
        assert_eq!(color("000000"), Color::Rgb(0, 0, 0));
        assert_eq!(color("ffffff"), Color::Rgb(0xff, 0xff, 0xff));
        assert_eq!(color("FFFFFF"), Color::Rgb(0xff, 0xff, 0xff));
    }

    #[test]
    fn deserializes_styles() {
        fn style(string: &str) -> Style {
            deserialize_style(de::IntoDeserializer::<de::value::Error>::into_deserializer(
                string,
            ))
            .expect("failed to deserialize style")
        }

        assert_eq!(style("none"), Style::default());
        assert_eq!(style("none:none"), Style::default());
        assert_eq!(style("none:none;"), Style::default());

        assert_eq!(style("black"), Style::default().fg(Color::Black));
        assert_eq!(
            style("black:white"),
            Style::default().fg(Color::Black).bg(Color::White)
        );

        assert_eq!(
            style("none;bold"),
            Style::default().add_modifier(Modifier::BOLD)
        );
        assert_eq!(
            style("none;bold;italic;underlined;"),
            Style::default()
                .add_modifier(Modifier::BOLD)
                .add_modifier(Modifier::ITALIC)
                .add_modifier(Modifier::UNDERLINED)
        );

        assert_eq!(
            style("00ff00:000000;bold;dim;italic;slow_blink"),
            Style::default()
                .fg(Color::Rgb(0, 0xff, 0))
                .bg(Color::Rgb(0, 0, 0))
                .add_modifier(Modifier::BOLD)
                .add_modifier(Modifier::DIM)
                .add_modifier(Modifier::ITALIC)
                .add_modifier(Modifier::SLOW_BLINK)
        );
    }

    #[test]
    fn deserializes_border_types() {
        fn border_type(string: &str) -> BorderType {
            deserialize_border_type(de::IntoDeserializer::<de::value::Error>::into_deserializer(
                string,
            ))
            .expect("failed to deserialize border type")
        }
        assert_eq!(border_type("plain"), BorderType::Plain);
        assert_eq!(border_type("rounded"), BorderType::Rounded);
        assert_eq!(border_type("double"), BorderType::Double);
        assert_eq!(border_type("thick"), BorderType::Thick);
        assert_eq!(border_type("quadrantinside"), BorderType::QuadrantInside);
        assert_eq!(border_type("quadrantoutside"), BorderType::QuadrantOutside);
    }
}