use std::fs;
use std::path::Path;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
use crate::shapes::types::{Shape, ShapeColor, ShapeType};
use crate::ui::menu::MenuValue;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SavedColor {
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
}
impl SavedColor {
pub fn from_color(color: Color) -> Self {
match color {
Color::Red => SavedColor::Red,
Color::Green => SavedColor::Green,
Color::Yellow => SavedColor::Yellow,
Color::Blue => SavedColor::Blue,
Color::Magenta => SavedColor::Magenta,
Color::Cyan => SavedColor::Cyan,
_ => SavedColor::Green, }
}
pub fn to_color(&self) -> Color {
match self {
SavedColor::Red => Color::Red,
SavedColor::Green => Color::Green,
SavedColor::Yellow => Color::Yellow,
SavedColor::Blue => Color::Blue,
SavedColor::Magenta => Color::Magenta,
SavedColor::Cyan => Color::Cyan,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SavedShapeType {
Circle,
Triangle,
Square,
Star,
LineStraight,
LineVertical,
}
impl From<ShapeType> for SavedShapeType {
fn from(shape_type: ShapeType) -> Self {
match shape_type {
ShapeType::Circle => SavedShapeType::Circle,
ShapeType::Triangle => SavedShapeType::Triangle,
ShapeType::Square => SavedShapeType::Square,
ShapeType::Star => SavedShapeType::Star,
ShapeType::LineStraight => SavedShapeType::LineStraight,
ShapeType::LineVertical => SavedShapeType::LineVertical,
}
}
}
impl From<SavedShapeType> for ShapeType {
fn from(saved: SavedShapeType) -> Self {
match saved {
SavedShapeType::Circle => ShapeType::Circle,
SavedShapeType::Triangle => ShapeType::Triangle,
SavedShapeType::Square => ShapeType::Square,
SavedShapeType::Star => ShapeType::Star,
SavedShapeType::LineStraight => ShapeType::LineStraight,
SavedShapeType::LineVertical => ShapeType::LineVertical,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedShape {
pub shape_type: SavedShapeType,
pub x: f32,
pub y: f32,
pub rotation_degrees: i32,
pub color: SavedColor,
}
impl SavedShape {
pub fn from_shape(shape: &Shape) -> Self {
let (x, y) = shape.position();
Self {
shape_type: shape.shape_type().into(),
x,
y,
rotation_degrees: shape.rotation_degrees(),
color: SavedColor::from_color(shape.color().color()),
}
}
pub fn to_shape_params(&self) -> (ShapeType, f32, f32, i32, ShapeColor) {
let shape_type: ShapeType = self.shape_type.clone().into();
(
shape_type,
self.x,
self.y,
self.rotation_degrees,
ShapeColor::new(self.color.to_color()),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedOptions {
#[serde(default)]
pub fps_cap: bool,
#[serde(default = "default_true")]
pub show_fps: bool,
#[serde(default = "default_ball_count")]
pub ball_count: i32,
#[serde(default = "default_percent")]
pub gravity_percent: i32,
#[serde(default = "default_percent")]
pub friction_percent: i32,
#[serde(default = "default_percent")]
pub force_percent: i32,
#[serde(default)]
pub color_mode: bool,
#[serde(default = "default_spawn_color")]
pub spawn_color_percent: i32,
}
fn default_true() -> bool {
true
}
fn default_ball_count() -> i32 {
5000
}
fn default_percent() -> i32 {
100
}
fn default_spawn_color() -> i32 {
80
}
impl Default for SavedOptions {
fn default() -> Self {
Self {
fps_cap: false,
show_fps: true,
ball_count: 5000,
gravity_percent: 100,
friction_percent: 100,
force_percent: 100,
color_mode: false,
spawn_color_percent: 80,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelConfig {
#[serde(default = "default_version")]
pub version: u32,
pub shapes: Vec<SavedShape>,
pub options: SavedOptions,
}
fn default_version() -> u32 {
1
}
impl LevelConfig {
pub fn new() -> Self {
Self {
version: 1,
shapes: Vec::new(),
options: SavedOptions::default(),
}
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> AppResult<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| AppError::Config(format!("Failed to serialize level config: {}", e)))?;
fs::write(path, json)
.map_err(|e| AppError::Config(format!("Failed to write level file: {}", e)))?;
Ok(())
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> AppResult<Self> {
let path_ref = path.as_ref();
let content = fs::read_to_string(path_ref).map_err(|e| {
AppError::Config(format!(
"Failed to read level file '{}': {}",
path_ref.display(),
e
))
})?;
let config: LevelConfig = serde_json::from_str(&content).map_err(|e| {
AppError::Config(format!(
"Failed to parse level file '{}': {}",
path_ref.display(),
e
))
})?;
Ok(config)
}
}
impl Default for LevelConfig {
fn default() -> Self {
Self::new()
}
}
pub fn options_from_menu(menu: &crate::ui::OptionsMenu) -> SavedOptions {
SavedOptions {
fps_cap: menu.fps_cap_enabled(),
show_fps: menu.show_fps(),
ball_count: menu.ball_count() as i32,
gravity_percent: menu.gravity_percent(),
friction_percent: menu.friction_percent(),
force_percent: (menu.force_percent() * 100.0) as i32,
color_mode: menu.color_mode(),
spawn_color_percent: menu.spawn_color_percent(),
}
}
pub fn apply_options_to_menu(options: &SavedOptions, menu: &mut crate::ui::OptionsMenu) {
menu.set_value("Frame Cap", MenuValue::Boolean(options.fps_cap));
menu.set_value("Show FPS", MenuValue::Boolean(options.show_fps));
menu.set_value("Ball Count", MenuValue::Integer(options.ball_count));
menu.set_value("Gravity %", MenuValue::Integer(options.gravity_percent));
menu.set_value("Friction %", MenuValue::Integer(options.friction_percent));
menu.set_value("Force %", MenuValue::Integer(options.force_percent));
menu.set_value("Color Mode", MenuValue::Boolean(options.color_mode));
menu.set_value(
"Spawn Color %",
MenuValue::Integer(options.spawn_color_percent),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_saved_color_roundtrip() {
let colors = [
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
];
for color in colors {
let saved = SavedColor::from_color(color);
let restored = saved.to_color();
assert_eq!(color, restored);
}
}
#[test]
fn test_saved_shape_type_roundtrip() {
for shape_type in ShapeType::all() {
let saved: SavedShapeType = shape_type.into();
let restored: ShapeType = saved.into();
assert_eq!(shape_type, restored);
}
}
#[test]
fn test_level_config_serialization() {
let config = LevelConfig {
version: 1,
shapes: vec![SavedShape {
shape_type: SavedShapeType::Circle,
x: 10.0,
y: 20.0,
rotation_degrees: 90,
color: SavedColor::Red,
}],
options: SavedOptions::default(),
};
let json = serde_json::to_string(&config).unwrap();
let restored: LevelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.version, 1);
assert_eq!(restored.shapes.len(), 1);
assert_eq!(restored.shapes[0].x, 10.0);
}
#[test]
fn test_malformed_json_error() {
let result: Result<LevelConfig, _> = serde_json::from_str("{ invalid json }");
assert!(result.is_err());
}
#[test]
fn test_default_values_on_missing_fields() {
let json = r#"{"shapes": [], "options": {}}"#;
let config: LevelConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.options.ball_count, 5000);
assert!(config.options.show_fps);
}
}