fastpack-tauri 0.29.8

Tauri GUI for FastPack
//! Shared application state for the Tauri backend.
//!
//! Per-window state is stored in a `HashMap<String, WindowState>` inside
//! `AppState`, keyed by the Tauri window label. Shared data like preferences
//! lives directly on `AppState`.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc;

use fastpack_core::types::config::Project;
use serde::{Deserialize, Serialize};

use crate::preferences::Preferences;
use crate::worker::SheetOutput;

/// Severity level for a log entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
    Info,
    Warn,
    Error,
}

/// A timestamped log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
    pub level: LogLevel,
    pub message: String,
    pub time: String,
}

/// Format the current UTC time as `HH:MM:SS`.
fn format_time() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let h = (secs % 86400) / 3600;
    let m = (secs % 3600) / 60;
    let s = secs % 60;
    format!("{h:02}:{m:02}:{s:02}")
}

impl LogEntry {
    /// Create an info-level log entry with the current timestamp.
    pub fn info(msg: impl Into<String>) -> Self {
        Self {
            level: LogLevel::Info,
            message: msg.into(),
            time: format_time(),
        }
    }
    /// Create a warn-level log entry with the current timestamp.
    pub fn warn(msg: impl Into<String>) -> Self {
        Self {
            level: LogLevel::Warn,
            message: msg.into(),
            time: format_time(),
        }
    }
    /// Create an error-level log entry with the current timestamp.
    pub fn error(msg: impl Into<String>) -> Self {
        Self {
            level: LogLevel::Error,
            message: msg.into(),
            time: format_time(),
        }
    }
}

/// Packed sheet data for the frontend (RGBA encoded as base64 PNG).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SheetData {
    /// Atlas width in pixels.
    pub width: u32,
    /// Atlas height in pixels.
    pub height: u32,
    /// PNG image encoded as base64.
    pub png_b64: String,
    /// Frame metadata for overlay rendering.
    pub frames: Vec<FrameData>,
}

/// Packed frame metadata sent to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrameData {
    /// Sprite identifier (forward-slash path without extension).
    pub id: String,
    /// Absolute path of the source image file.
    pub src_path: String,
    /// X offset in the atlas texture.
    pub x: u32,
    /// Y offset in the atlas texture.
    pub y: u32,
    /// Frame width in pixels.
    pub w: u32,
    /// Frame height in pixels.
    pub h: u32,
    /// If this frame is an alias, the ID of the canonical sprite.
    pub alias_of: Option<String>,
}

/// Handle for a running filesystem watcher.
pub struct WatcherHandle {
    /// The debounced filesystem watcher instance.
    pub _debouncer:
        notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
    /// Channel sender to stop the watcher. Dropping the handle also stops it.
    pub stop_tx: mpsc::SyncSender<()>,
}

/// Per-window runtime state. Each open window has its own project, pack
/// results, log history, and filesystem watcher.
pub struct WindowState {
    /// The current project configuration and sources.
    pub project: Project,
    /// Path to the `.fpsheet` file on disk. `None` for unsaved projects.
    pub project_path: Option<PathBuf>,
    /// True if the project has unsaved changes.
    pub dirty: bool,
    /// Ordered log entries shown in the UI console.
    pub log: Vec<LogEntry>,
    /// Packed sheet data from the last successful pack.
    pub sheets: Vec<SheetData>,
    /// Total number of sprites in the last pack.
    pub sprite_count: usize,
    /// Number of duplicate sprites detected as aliases.
    pub alias_count: usize,
    /// Number of sprites that did not fit in the atlas.
    pub overflow_count: usize,
    /// True while a pack or publish operation runs on a background thread.
    pub is_packing: bool,
    /// Active filesystem watcher for auto-repack. `None` when watch mode is off.
    pub watcher: Option<WatcherHandle>,
}

/// All runtime state shared across Tauri commands. Per-window state lives in
/// `windows`, keyed by the Tauri window label. Shared data like preferences
/// lives directly here.
pub struct AppState {
    /// Map of window label → per-window state.
    pub windows: HashMap<String, WindowState>,
    /// User preferences loaded from disk (shared across all windows).
    pub prefs: Preferences,
    /// Counter for generating unique window labels.
    next_window_id: u32,
}

impl WindowState {
    /// Build initial state for a new window. If `project_path` is given, load
    /// that `.fpsheet` file.
    pub fn new(project_path: Option<PathBuf>, prefs: &Preferences) -> Self {
        let mut state = Self {
            project: Project {
                config: prefs.default_config.clone(),
                sources: Vec::new(),
                folder_colors: Default::default(),
            },
            project_path: None,
            dirty: false,
            log: Vec::new(),
            sheets: Vec::new(),
            sprite_count: 0,
            alias_count: 0,
            overflow_count: 0,
            is_packing: false,
            watcher: None,
        };

        if let Some(path) = project_path {
            match std::fs::read_to_string(&path) {
                Ok(text) => match toml::from_str(&text) {
                    Ok(project) => {
                        state.project = project;
                        state.project_path = Some(path);
                        state.log.push(LogEntry::info("Project loaded."));
                    }
                    Err(e) => state.log.push(LogEntry::error(format!("Parse error: {e}"))),
                },
                Err(e) => state.log.push(LogEntry::error(format!("Read error: {e}"))),
            }
        }

        state
    }

    /// Push an info-level message to the log.
    pub fn log_info(&mut self, msg: impl Into<String>) {
        self.log.push(LogEntry::info(msg));
    }
    /// Push a warn-level message to the log.
    pub fn log_warn(&mut self, msg: impl Into<String>) {
        self.log.push(LogEntry::warn(msg));
    }
    /// Push an error-level message to the log.
    pub fn log_error(&mut self, msg: impl Into<String>) {
        self.log.push(LogEntry::error(msg));
    }
}

impl AppState {
    /// Build initial app state. Creates one window ("main") with the given
    /// optional project path.
    pub fn new(project_path: Option<PathBuf>) -> Self {
        let prefs = Preferences::load();
        let initial = WindowState::new(project_path, &prefs);
        let mut windows = HashMap::new();
        windows.insert("main".to_string(), initial);
        Self {
            windows,
            prefs,
            next_window_id: 1,
        }
    }

    /// Get the window state for the given label, creating a blank project for
    /// windows that have not been seen yet.
    pub fn window_mut(&mut self, label: &str) -> &mut WindowState {
        let prefs = &self.prefs;
        self.windows
            .entry(label.to_string())
            .or_insert_with(|| WindowState::new(None, prefs))
    }

    /// Generate the next unique window label (e.g. "main-1", "main-2").
    pub fn next_label(&mut self) -> String {
        let id = self.next_window_id;
        self.next_window_id += 1;
        format!("main-{id}")
    }
}

/// Convert a raw [`SheetOutput`] into [`SheetData`] by PNG-encoding the RGBA buffer.
pub fn sheet_to_data(sheet: &SheetOutput) -> SheetData {
    use base64::Engine;
    use image::{ImageBuffer, Rgba};

    let img: ImageBuffer<Rgba<u8>, _> =
        ImageBuffer::from_raw(sheet.width, sheet.height, sheet.rgba.clone())
            .expect("valid rgba buffer");

    let mut png_bytes: Vec<u8> = Vec::new();
    img.write_to(
        &mut std::io::Cursor::new(&mut png_bytes),
        image::ImageFormat::Png,
    )
    .expect("png encode");

    let png_b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);

    let frames = sheet
        .frames
        .iter()
        .map(|f| FrameData {
            id: f.id.clone(),
            src_path: f.src_path.clone(),
            x: f.x,
            y: f.y,
            w: f.w,
            h: f.h,
            alias_of: f.alias_of.clone(),
        })
        .collect();

    SheetData {
        width: sheet.width,
        height: sheet.height,
        png_b64,
        frames,
    }
}