term39 1.5.1

A modern, retro-styled terminal multiplexer with a classic MS-DOS aesthetic
//! Framebuffer configuration management
//!
//! This module handles loading and saving framebuffer-specific settings
//! from the fb.toml configuration file.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Display configuration for framebuffer mode
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayConfig {
    /// Text mode (e.g., "80x25", "80x50", "160x100")
    #[serde(default = "default_mode")]
    pub mode: String,
    /// Pixel scale factor ("auto", "1", "2", "3", "4", etc.)
    #[serde(default = "default_scale")]
    pub scale: String,
}

fn default_mode() -> String {
    "80x25".to_string()
}

fn default_scale() -> String {
    "auto".to_string()
}

impl Default for DisplayConfig {
    fn default() -> Self {
        Self {
            mode: default_mode(),
            scale: default_scale(),
        }
    }
}

/// Font configuration for framebuffer mode
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FontConfig {
    /// Console font name (e.g., "Unifont-APL8x16")
    #[serde(default = "default_font_name")]
    pub name: String,
}

fn default_font_name() -> String {
    "Unifont-APL8x16".to_string()
}

impl Default for FontConfig {
    fn default() -> Self {
        Self {
            name: default_font_name(),
        }
    }
}

/// Mouse configuration for framebuffer mode
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MouseConfig {
    /// Mouse input device path (optional, auto-detect if not specified)
    #[serde(default)]
    pub device: Option<String>,
    /// Invert X-axis movement
    #[serde(default)]
    pub invert_x: bool,
    /// Invert Y-axis movement
    #[serde(default)]
    pub invert_y: bool,
    /// Swap left and right mouse buttons
    #[serde(default)]
    pub swap_buttons: bool,
    /// Mouse sensitivity (0.1-5.0, None = auto-calculate based on screen size)
    #[serde(default)]
    pub sensitivity: Option<f32>,
}

/// Main framebuffer configuration structure
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FramebufferConfig {
    /// Display settings
    #[serde(default)]
    pub display: DisplayConfig,
    /// Font settings
    #[serde(default)]
    pub font: FontConfig,
    /// Mouse settings
    #[serde(default)]
    pub mouse: MouseConfig,
}

impl FramebufferConfig {
    /// Get the configuration file path
    /// Returns ~/Library/Application Support/term39/fb.toml on macOS
    /// Returns ~/.config/term39/fb.toml on Linux
    /// Returns %APPDATA%\term39\fb.toml on Windows
    pub fn config_path() -> Option<PathBuf> {
        let config_dir = dirs::config_dir()?;
        let app_config_dir = config_dir.join("term39");
        Some(app_config_dir.join("fb.toml"))
    }

    /// Check if configuration file exists
    pub fn exists() -> bool {
        Self::config_path().map(|p| p.exists()).unwrap_or(false)
    }

    /// Load configuration from file, returning default if it doesn't exist
    pub fn load() -> Self {
        let path = match Self::config_path() {
            Some(p) => p,
            None => return Self::default(),
        };

        // If config file doesn't exist, return default (don't create it)
        if !path.exists() {
            return Self::default();
        }

        // Read and parse config file
        match fs::read_to_string(&path) {
            Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
            Err(_) => Self::default(),
        }
    }

    /// Save configuration to file
    pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
        let path = Self::config_path().ok_or("Could not determine config path")?;

        // Create config directory if it doesn't exist
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }

        // Serialize and write config with header comment
        let toml_string = toml::to_string_pretty(self)?;
        let content = format!(
            "# term39 Framebuffer Configuration\n\
             # Generated by term39 --fb-setup\n\
             # Edit this file to customize framebuffer settings\n\
             # or run term39 --fb-setup to use the configuration wizard\n\n\
             {}\n",
            toml_string
        );
        fs::write(path, content)?;

        Ok(())
    }

    /// Available text modes
    pub const TEXT_MODES: [&'static str; 8] = [
        "40x25", "80x25", "80x43", "80x50", "160x50", "160x100", "320x100", "320x200",
    ];

    /// Text mode descriptions for display
    pub const TEXT_MODE_DESCRIPTIONS: [&'static str; 8] = [
        "16x16 cells",
        "8x16 cells - Standard DOS",
        "8x11 cells",
        "8x8 cells - High density",
        "8x16 cells - Double-wide",
        "8x16 cells - High resolution",
        "8x16 cells - Ultra-wide",
        "8x8 cells - Maximum",
    ];

    /// Available scale options
    pub const SCALE_OPTIONS: [&'static str; 5] = ["auto", "1", "2", "3", "4"];

    /// Available mouse device options
    pub const MOUSE_DEVICE_OPTIONS: [&'static str; 6] = [
        "auto",
        "/dev/input/mice",
        "/dev/input/event0",
        "/dev/input/event1",
        "/dev/input/event2",
        "/dev/input/event3",
    ];

    /// Required font dimensions (width, height) for each text mode
    /// Index corresponds to TEXT_MODES array
    pub const TEXT_MODE_FONT_DIMS: [(usize, usize); 8] = [
        (16, 16), // 40x25
        (8, 16),  // 80x25
        (8, 11),  // 80x43
        (8, 8),   // 80x50
        (8, 16),  // 160x50
        (8, 16),  // 160x100
        (8, 16),  // 320x100
        (8, 8),   // 320x200
    ];

    /// Get the required font dimensions for a mode index
    pub fn get_mode_font_dims(mode_index: usize) -> (usize, usize) {
        Self::TEXT_MODE_FONT_DIMS
            .get(mode_index)
            .copied()
            .unwrap_or((8, 16))
    }

    /// Get the index of the current mode in TEXT_MODES
    pub fn mode_index(&self) -> usize {
        Self::TEXT_MODES
            .iter()
            .position(|&m| m == self.display.mode)
            .unwrap_or(1) // Default to 80x25
    }

    /// Get the index of the current scale in SCALE_OPTIONS
    pub fn scale_index(&self) -> usize {
        Self::SCALE_OPTIONS
            .iter()
            .position(|&s| s == self.display.scale)
            .unwrap_or(0) // Default to auto
    }

    /// Set mode by index
    pub fn set_mode_by_index(&mut self, index: usize) {
        if index < Self::TEXT_MODES.len() {
            self.display.mode = Self::TEXT_MODES[index].to_string();
        }
    }

    /// Set scale by index
    pub fn set_scale_by_index(&mut self, index: usize) {
        if index < Self::SCALE_OPTIONS.len() {
            self.display.scale = Self::SCALE_OPTIONS[index].to_string();
        }
    }

    /// Cycle to the next scale option
    pub fn cycle_scale(&mut self) {
        let current = self.scale_index();
        let next = (current + 1) % Self::SCALE_OPTIONS.len();
        self.set_scale_by_index(next);
    }

    /// Cycle to the previous scale option
    pub fn cycle_scale_reverse(&mut self) {
        let current = self.scale_index();
        let prev = if current == 0 {
            Self::SCALE_OPTIONS.len() - 1
        } else {
            current - 1
        };
        self.set_scale_by_index(prev);
    }

    /// Toggle invert X
    pub fn toggle_invert_x(&mut self) {
        self.mouse.invert_x = !self.mouse.invert_x;
    }

    /// Toggle invert Y
    pub fn toggle_invert_y(&mut self) {
        self.mouse.invert_y = !self.mouse.invert_y;
    }

    /// Toggle swap mouse buttons
    pub fn toggle_swap_buttons(&mut self) {
        self.mouse.swap_buttons = !self.mouse.swap_buttons;
    }

    /// Get the index of the current mouse device in MOUSE_DEVICE_OPTIONS
    pub fn device_index(&self) -> usize {
        match &self.mouse.device {
            None => 0, // "auto"
            Some(dev) => Self::MOUSE_DEVICE_OPTIONS
                .iter()
                .position(|&d| d == dev)
                .unwrap_or(0),
        }
    }

    /// Set mouse device by index
    pub fn set_device_by_index(&mut self, index: usize) {
        if index < Self::MOUSE_DEVICE_OPTIONS.len() {
            let device = Self::MOUSE_DEVICE_OPTIONS[index];
            if device == "auto" {
                self.mouse.device = None;
            } else {
                self.mouse.device = Some(device.to_string());
            }
        }
    }

    /// Cycle to the next mouse device option
    pub fn cycle_device(&mut self) {
        let current = self.device_index();
        let next = (current + 1) % Self::MOUSE_DEVICE_OPTIONS.len();
        self.set_device_by_index(next);
    }

    /// Cycle to the previous mouse device option
    pub fn cycle_device_reverse(&mut self) {
        let current = self.device_index();
        let prev = if current == 0 {
            Self::MOUSE_DEVICE_OPTIONS.len() - 1
        } else {
            current - 1
        };
        self.set_device_by_index(prev);
    }

    /// Get display name for current mouse device
    pub fn device_display_name(&self) -> &str {
        match &self.mouse.device {
            None => "auto",
            Some(dev) => dev,
        }
    }

    /// Get the actual mouse device path to use
    /// Returns the configured device, or the default "/dev/input/mice" for "auto"
    pub fn get_mouse_device(&self) -> String {
        match &self.mouse.device {
            None => "/dev/input/mice".to_string(),
            Some(dev) => dev.clone(),
        }
    }
}

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

    #[test]
    fn test_default_config() {
        let config = FramebufferConfig::default();
        assert_eq!(config.display.mode, "80x25");
        assert_eq!(config.display.scale, "auto");
        assert_eq!(config.font.name, "Unifont-APL8x16");
        assert!(!config.mouse.invert_x);
        assert!(!config.mouse.invert_y);
        assert!(config.mouse.device.is_none());
    }

    #[test]
    fn test_mode_index() {
        let config = FramebufferConfig::default();
        assert_eq!(config.mode_index(), 1); // 80x25 is at index 1
    }

    #[test]
    fn test_scale_index() {
        let config = FramebufferConfig::default();
        assert_eq!(config.scale_index(), 0); // auto is at index 0
    }

    #[test]
    fn test_set_mode_by_index() {
        let mut config = FramebufferConfig::default();
        config.set_mode_by_index(3); // 80x50
        assert_eq!(config.display.mode, "80x50");
    }

    #[test]
    fn test_cycle_scale() {
        let mut config = FramebufferConfig::default();
        assert_eq!(config.display.scale, "auto");
        config.cycle_scale();
        assert_eq!(config.display.scale, "1");
        config.cycle_scale();
        assert_eq!(config.display.scale, "2");
    }

    #[test]
    fn test_toggle_invert() {
        let mut config = FramebufferConfig::default();
        assert!(!config.mouse.invert_x);
        config.toggle_invert_x();
        assert!(config.mouse.invert_x);
        config.toggle_invert_x();
        assert!(!config.mouse.invert_x);
    }

    #[test]
    fn test_serialization() {
        let config = FramebufferConfig::default();
        let toml_str = toml::to_string(&config).unwrap();
        let parsed: FramebufferConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(config.display.mode, parsed.display.mode);
        assert_eq!(config.font.name, parsed.font.name);
    }
}