vpin 0.23.5

Rust library for working with Visual Pinball VPX files
Documentation
use super::vertex2d::Vertex2D;
use crate::impl_shared_attributes;
use crate::vpx::gameitem::font::FontJson;
use crate::vpx::gameitem::select::{TimerData, WriteSharedAttributes};
use crate::vpx::{
    biff::{self, BiffRead, BiffReader, BiffWrite},
    color::Color,
    gameitem::font::Font,
};
use log::warn;
use serde::{Deserialize, Deserializer, Serialize, Serializer};

#[derive(Debug, PartialEq, Clone, Default)]
#[cfg_attr(test, derive(fake::Dummy))]
pub enum TextAlignment {
    #[default]
    Left = 0,
    Center = 1,
    Right = 2,
}

impl From<u32> for TextAlignment {
    fn from(value: u32) -> Self {
        match value {
            0 => TextAlignment::Left,
            1 => TextAlignment::Center,
            2 => TextAlignment::Right,
            _ => panic!("Invalid value for TextAlignment: {value}"),
        }
    }
}

impl From<&TextAlignment> for u32 {
    fn from(value: &TextAlignment) -> Self {
        match value {
            TextAlignment::Left => 0,
            TextAlignment::Center => 1,
            TextAlignment::Right => 2,
        }
    }
}

/// Serialize to lowercase string
impl Serialize for TextAlignment {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            TextAlignment::Left => serializer.serialize_str("left"),
            TextAlignment::Center => serializer.serialize_str("center"),
            TextAlignment::Right => serializer.serialize_str("right"),
        }
    }
}

/// Deserialize from lowercase string
/// or number for backwards compatibility
impl<'de> Deserialize<'de> for TextAlignment {
    fn deserialize<D>(deserializer: D) -> Result<TextAlignment, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        struct TextAlignmentVisitor;

        impl serde::de::Visitor<'_> for TextAlignmentVisitor {
            type Value = TextAlignment;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("a string or number representing a TargetType")
            }

            fn visit_u64<E>(self, value: u64) -> Result<TextAlignment, E>
            where
                E: serde::de::Error,
            {
                match value {
                    0 => Ok(TextAlignment::Left),
                    1 => Ok(TextAlignment::Center),
                    2 => Ok(TextAlignment::Right),
                    _ => Err(serde::de::Error::invalid_value(
                        serde::de::Unexpected::Unsigned(value),
                        &"0, 1, or 2",
                    )),
                }
            }

            fn visit_str<E>(self, value: &str) -> Result<TextAlignment, E>
            where
                E: serde::de::Error,
            {
                match value {
                    "left" => Ok(TextAlignment::Left),
                    "center" => Ok(TextAlignment::Center),
                    "right" => Ok(TextAlignment::Right),
                    _ => Err(serde::de::Error::unknown_variant(
                        value,
                        &["left", "center", "right"],
                    )),
                }
            }
        }

        deserializer.deserialize_any(TextAlignmentVisitor)
    }
}

#[derive(Debug, PartialEq)]
#[cfg_attr(test, derive(fake::Dummy))]
pub struct TextBox {
    pub ver1: Vertex2D,       // VER1
    pub ver2: Vertex2D,       // VER2
    pub back_color: Color,    // CLRB
    pub font_color: Color,    // CLRF
    pub intensity_scale: f32, // INSC
    pub text: String,         // TEXT
    pub name: String,         // NAME
    pub align: TextAlignment, // ALGN
    pub is_transparent: bool, // TRNS
    pub is_dmd: Option<bool>, // IDMD added in 10.2?
    pub font: Font,           // FONT

    /// Timer data for scripting (shared across all game items).
    /// See [`TimerData`] for details.
    pub timer: TimerData,

    // these are shared between all items
    pub is_locked: bool,
    // LOCK
    pub editor_layer: Option<u32>,
    // LAYR
    pub editor_layer_name: Option<String>,
    // LANR default "Layer_{editor_layer + 1}"
    pub editor_layer_visibility: Option<bool>, // LVIS
    /// Added in 10.8.1
    pub part_group_name: Option<String>,
}
impl_shared_attributes!(TextBox);

#[derive(Serialize, Deserialize)]
struct TextBoxJson {
    ver1: Vertex2D,
    ver2: Vertex2D,
    back_color: Color,
    font_color: Color,
    intensity_scale: f32,
    text: String,
    #[serde(flatten)]
    pub timer: TimerData,
    name: String,
    align: TextAlignment,
    is_transparent: bool,
    is_dmd: Option<bool>,
    font: FontJson,
    #[serde(skip_serializing_if = "Option::is_none")]
    part_group_name: Option<String>,
}

impl TextBoxJson {
    fn from_textbox(textbox: &TextBox) -> Self {
        Self {
            ver1: textbox.ver1,
            ver2: textbox.ver2,
            back_color: textbox.back_color,
            font_color: textbox.font_color,
            intensity_scale: textbox.intensity_scale,
            text: textbox.text.clone(),
            timer: textbox.timer.clone(),
            name: textbox.name.clone(),
            align: textbox.align.clone(),
            is_transparent: textbox.is_transparent,
            is_dmd: textbox.is_dmd,
            font: FontJson::from_font(&textbox.font),
            part_group_name: textbox.part_group_name.clone(),
        }
    }

    fn into_textbox(self) -> TextBox {
        TextBox {
            ver1: self.ver1,
            ver2: self.ver2,
            back_color: self.back_color,
            font_color: self.font_color,
            intensity_scale: self.intensity_scale,
            text: self.text,
            timer: self.timer.clone(),
            name: self.name,
            align: self.align,
            is_transparent: self.is_transparent,
            is_dmd: self.is_dmd,
            font: self.font.to_font(),
            // this is populated from a different file
            is_locked: false,
            // this is populated from a different file
            editor_layer: None,
            // this is populated from a different file
            editor_layer_name: None,
            // this is populated from a different file
            editor_layer_visibility: None,
            part_group_name: self.part_group_name,
        }
    }
}

impl Serialize for TextBox {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        TextBoxJson::from_textbox(self).serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for TextBox {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let textbox_json = TextBoxJson::deserialize(deserializer)?;
        Ok(textbox_json.into_textbox())
    }
}

impl Default for TextBox {
    fn default() -> Self {
        Self {
            ver1: Vertex2D::default(),
            ver2: Vertex2D::default(),
            back_color: Color::BLACK,
            font_color: Color::WHITE,
            intensity_scale: 1.0,
            text: Default::default(),
            timer: TimerData::default(),
            name: Default::default(),
            align: Default::default(),
            is_transparent: false,
            is_dmd: None,
            font: Font::default(),
            is_locked: false,
            editor_layer: Default::default(),
            editor_layer_name: None,
            editor_layer_visibility: None,
            part_group_name: None,
        }
    }
}

impl BiffRead for TextBox {
    fn biff_read(reader: &mut BiffReader<'_>) -> Self {
        let mut textbox = TextBox::default();

        loop {
            reader.next(biff::WARN);
            if reader.is_eof() {
                break;
            }
            let tag = reader.tag();
            let tag_str = tag.as_str();
            match tag_str {
                "VER1" => {
                    textbox.ver1 = Vertex2D::biff_read(reader);
                }
                "VER2" => {
                    textbox.ver2 = Vertex2D::biff_read(reader);
                }
                "CLRB" => {
                    textbox.back_color = Color::biff_read(reader);
                }
                "CLRF" => {
                    textbox.font_color = Color::biff_read(reader);
                }
                "INSC" => {
                    textbox.intensity_scale = reader.get_f32();
                }
                "TEXT" => {
                    textbox.text = reader.get_string();
                }
                "NAME" => {
                    textbox.name = reader.get_wide_string();
                }
                "ALGN" => {
                    textbox.align = reader.get_u32().into();
                }
                "TRNS" => {
                    textbox.is_transparent = reader.get_bool();
                }
                "IDMD" => {
                    textbox.is_dmd = Some(reader.get_bool());
                }

                "FONT" => {
                    textbox.font = Font::biff_read(reader);
                }
                _ => {
                    if !textbox.timer.biff_read_tag(tag_str, reader)
                        && !textbox.read_shared_attribute(tag_str, reader)
                    {
                        warn!(
                            "Unknown tag {} for {}",
                            tag_str,
                            std::any::type_name::<Self>()
                        );
                        reader.skip_tag();
                    }
                }
            }
        }
        textbox
    }
}

impl BiffWrite for TextBox {
    fn biff_write(&self, writer: &mut biff::BiffWriter) {
        writer.write_tagged("VER1", &self.ver1);
        writer.write_tagged("VER2", &self.ver2);
        writer.write_tagged_with("CLRB", &self.back_color, Color::biff_write);
        writer.write_tagged_with("CLRF", &self.font_color, Color::biff_write);
        writer.write_tagged_f32("INSC", self.intensity_scale);
        writer.write_tagged_string("TEXT", &self.text);
        self.timer.biff_write(writer);
        writer.write_tagged_wide_string("NAME", &self.name);
        writer.write_tagged_u32("ALGN", (&self.align).into());
        writer.write_tagged_bool("TRNS", self.is_transparent);
        if let Some(is_dmd) = self.is_dmd {
            writer.write_tagged_bool("IDMD", is_dmd);
        }

        self.write_shared_attributes(writer);

        writer.write_tagged_without_size("FONT", &self.font);

        writer.close(true);
    }
}

#[cfg(test)]
mod tests {
    use crate::vpx::biff::BiffWriter;
    use fake::{Fake, Faker};
    use std::collections::HashSet;

    use super::*;
    use crate::vpx::gameitem::font::{CHARSET_ANSI, FontStyle};
    use pretty_assertions::assert_eq;

    #[test]
    fn test_write_read() {
        let textbox = TextBox {
            ver1: Vertex2D::new(1.0, 2.0),
            ver2: Vertex2D::new(3.0, 4.0),
            back_color: Faker.fake(),
            font_color: Faker.fake(),
            intensity_scale: 1.0,
            text: "test text".to_string(),
            timer: TimerData {
                is_enabled: true,
                interval: 3,
            },
            name: "test timer".to_string(),
            align: Faker.fake(),
            is_transparent: false,
            is_dmd: Some(false),
            font: Font::new(
                CHARSET_ANSI,
                HashSet::from([FontStyle::Bold, FontStyle::Underline]),
                123,
                456,
                "test font".to_string(),
            ),
            is_locked: false,
            editor_layer: Some(1),
            editor_layer_name: Some("test layer".to_string()),
            editor_layer_visibility: Some(true),
            part_group_name: Some("test group".to_string()),
        };
        let mut writer = BiffWriter::new();
        TextBox::biff_write(&textbox, &mut writer);
        let textbox_read = TextBox::biff_read(&mut BiffReader::new(writer.get_data()));
        assert_eq!(textbox, textbox_read);
    }

    #[test]
    fn test_text_alignment_json() {
        let sizing_type = TextAlignment::Center;
        let json = serde_json::to_string(&sizing_type).unwrap();
        assert_eq!(json, "\"center\"");
        let sizing_type_read: TextAlignment = serde_json::from_str(&json).unwrap();
        assert_eq!(sizing_type, sizing_type_read);
        let json = serde_json::Value::from(2);
        let sizing_type_read: TextAlignment = serde_json::from_value(json).unwrap();
        assert_eq!(TextAlignment::Right, sizing_type_read);
    }

    #[test]
    #[should_panic = "Error(\"unknown variant `foo`, expected one of `left`, `center`, `right`\", line: 0, column: 0)"]
    fn test_text_alignment_json_fail_string() {
        let json = serde_json::Value::from("foo");
        let _: TextAlignment = serde_json::from_value(json).unwrap();
    }
}