Skip to main content

fastpack_gui/
state.rs

1use std::path::PathBuf;
2
3use fastpack_core::types::{
4    atlas::AtlasFrame,
5    config::{PackerConfig, Project, SourceSpec},
6};
7use rust_i18n::t;
8
9/// Severity level for a log entry.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum LogLevel {
12    /// Informational message.
13    Info,
14    /// Non-fatal warning.
15    Warn,
16    /// Operation failure.
17    Error,
18}
19
20/// A single timestamped output log entry.
21pub struct LogEntry {
22    /// Severity of the message.
23    pub level: LogLevel,
24    /// Human-readable message text.
25    pub message: String,
26    /// Local time string in HH:MM:SS format.
27    pub time: String,
28}
29
30fn format_time() -> String {
31    use std::time::{SystemTime, UNIX_EPOCH};
32    let secs = SystemTime::now()
33        .duration_since(UNIX_EPOCH)
34        .unwrap_or_default()
35        .as_secs();
36    let h = (secs % 86400) / 3600;
37    let m = (secs % 3600) / 60;
38    let s = secs % 60;
39    format!("{h:02}:{m:02}:{s:02}")
40}
41
42impl LogEntry {
43    /// Create an info-level log entry.
44    pub fn info(msg: impl Into<String>) -> Self {
45        Self {
46            level: LogLevel::Info,
47            message: msg.into(),
48            time: format_time(),
49        }
50    }
51    /// Create a warn-level log entry.
52    pub fn warn(msg: impl Into<String>) -> Self {
53        Self {
54            level: LogLevel::Warn,
55            message: msg.into(),
56            time: format_time(),
57        }
58    }
59    /// Create an error-level log entry.
60    pub fn error(msg: impl Into<String>) -> Self {
61        Self {
62            level: LogLevel::Error,
63            message: msg.into(),
64            time: format_time(),
65        }
66    }
67}
68
69/// A single frame in the packed atlas (used by sprite list + preview).
70#[derive(Clone)]
71pub struct FrameInfo {
72    /// Sprite identifier used in export data.
73    pub id: String,
74    /// Index of the atlas sheet this frame lives on.
75    pub sheet_idx: usize,
76    /// Packed X position in atlas pixels.
77    pub x: u32,
78    /// Packed Y position in atlas pixels.
79    pub y: u32,
80    /// Packed frame width in pixels.
81    pub w: u32,
82    /// Packed frame height in pixels.
83    pub h: u32,
84    /// Set to the canonical sprite ID if this frame is a duplicate.
85    pub alias_of: Option<String>,
86}
87
88/// Per-sheet atlas data kept in AppState after a pack run.
89pub struct SheetData {
90    /// Raw RGBA pixel data.
91    pub rgba: Vec<u8>,
92    /// Atlas width in pixels.
93    pub width: u32,
94    /// Atlas height in pixels.
95    pub height: u32,
96    /// UI-facing frame metadata for this sheet.
97    pub frames: Vec<FrameInfo>,
98    /// Full atlas frame data used by exporters.
99    pub atlas_frames: Vec<AtlasFrame>,
100}
101
102/// One-shot flags set by menus/toolbar and consumed by the app's update loop.
103#[derive(Default)]
104pub struct PendingActions {
105    /// Trigger a new pack run.
106    pub pack: bool,
107    /// Export the last packed result.
108    pub export: bool,
109    /// Clear state and start a new project.
110    pub new_project: bool,
111    /// Open an existing `.fpsheet` file.
112    pub open_project: bool,
113    /// Save the project to its current path.
114    pub save_project: bool,
115    /// Save the project to a user-chosen path.
116    pub save_project_as: bool,
117    /// Open a folder picker and add a source directory.
118    pub add_source: bool,
119    /// Open the preferences window.
120    pub open_prefs: bool,
121    /// Rebuild the filesystem watcher to match current sources.
122    pub rebuild_watcher: bool,
123}
124
125/// Playback state for the animation preview window.
126pub struct AnimPreviewState {
127    /// Whether the preview window is open.
128    pub open: bool,
129    /// Whether playback is running.
130    pub playing: bool,
131    /// Frames per second for playback.
132    pub fps: f32,
133    /// Whether to loop back to the first frame after the last.
134    pub looping: bool,
135    /// Index into `AppState.selected_frames` for the currently displayed frame.
136    pub current_frame: usize,
137    /// Accumulated time since the last frame advance (seconds).
138    pub elapsed_secs: f64,
139    /// Zoom scale for the canvas (1.0 = pixel-perfect).
140    pub zoom: f32,
141    /// Pan offset for the canvas (screen pixels from centre).
142    pub pan: [f32; 2],
143}
144
145impl Default for AnimPreviewState {
146    fn default() -> Self {
147        Self {
148            open: false,
149            playing: false,
150            fps: 24.0,
151            looping: true,
152            current_frame: 0,
153            elapsed_secs: 0.0,
154            zoom: 1.0,
155            pan: [0.0, 0.0],
156        }
157    }
158}
159
160/// All runtime state shared across the GUI.
161pub struct AppState {
162    /// Project configuration and source specs (serialised to/from .fpsheet).
163    pub project: Project,
164    /// Path of the currently open .fpsheet file, if any.
165    pub project_path: Option<PathBuf>,
166    /// True when there are unsaved changes.
167    pub dirty: bool,
168
169    /// Messages shown in the output log panel.
170    pub log: Vec<LogEntry>,
171
172    /// All packed sheets from the last successful pack.
173    pub sheets: Vec<SheetData>,
174    /// Frame entries from all sheets concatenated, in sheet order.
175    pub frames: Vec<FrameInfo>,
176    /// Counts from the last pack run.
177    pub sprite_count: usize,
178    /// Sprites deduplicated as aliases in the last pack.
179    pub alias_count: usize,
180    /// Sprites that did not fit when multipack is disabled.
181    pub overflow_count: usize,
182
183    /// True while a pack is running in the background.
184    pub packing: bool,
185
186    /// Indices into `self.frames` of highlighted frames, in click order.
187    pub selected_frames: Vec<usize>,
188    /// Anchor frame for shift+click range selection. Set on plain click.
189    pub anchor_frame: Option<usize>,
190    /// State for the animation preview window.
191    pub anim_preview: AnimPreviewState,
192
193    /// Pan offset for the atlas preview (screen pixels from the centre).
194    pub atlas_pan: [f32; 2],
195    /// Zoom scale for the atlas preview (1.0 = pixel-perfect).
196    pub atlas_zoom: f32,
197
198    /// True for the custom dark theme, false for light.
199    pub dark_mode: bool,
200
201    /// One-shot actions queued by menus and toolbar, processed at frame start.
202    pub pending: PendingActions,
203}
204
205impl Default for AppState {
206    fn default() -> Self {
207        Self {
208            project: Project::default(),
209            project_path: None,
210            dirty: false,
211            log: Vec::new(),
212            sheets: Vec::new(),
213            frames: Vec::new(),
214            sprite_count: 0,
215            alias_count: 0,
216            overflow_count: 0,
217            packing: false,
218            selected_frames: Vec::new(),
219            anchor_frame: None,
220            anim_preview: AnimPreviewState::default(),
221            atlas_pan: [0.0, 0.0],
222            atlas_zoom: 1.0,
223            dark_mode: true,
224            pending: PendingActions::default(),
225        }
226    }
227}
228
229impl AppState {
230    /// Append an info message to the output log.
231    pub fn log_info(&mut self, msg: impl Into<String>) {
232        self.log.push(LogEntry::info(msg));
233    }
234    /// Append a warning message to the output log.
235    pub fn log_warn(&mut self, msg: impl Into<String>) {
236        self.log.push(LogEntry::warn(msg));
237    }
238    /// Append an error message to the output log.
239    pub fn log_error(&mut self, msg: impl Into<String>) {
240        self.log.push(LogEntry::error(msg));
241    }
242
243    /// Window title with project name and dirty marker.
244    pub fn window_title(&self) -> String {
245        let name = self
246            .project_path
247            .as_ref()
248            .and_then(|p| p.file_stem())
249            .map(|s| s.to_string_lossy().into_owned())
250            .unwrap_or_else(|| t!("state.untitled").to_string());
251        if self.dirty {
252            format!("FastPack — {}*", name)
253        } else {
254            format!("FastPack — {}", name)
255        }
256    }
257
258    /// Discard all state and start fresh using the given default config.
259    pub fn new_project(&mut self, default_config: PackerConfig) {
260        let dark_mode = self.dark_mode;
261        *self = AppState::default();
262        self.dark_mode = dark_mode;
263        self.project.config = default_config;
264        self.log.push(LogEntry::info(t!("state.new_project")));
265    }
266
267    /// Add a source directory and schedule an auto-pack. No-ops if already tracked.
268    pub fn add_source_path(&mut self, path: PathBuf) {
269        let path = std::fs::canonicalize(&path).unwrap_or(path);
270        // Skip if any existing source already covers this path (same dir or parent of it).
271        if self.project.sources.iter().any(|s| {
272            let stored = std::fs::canonicalize(&s.path).unwrap_or_else(|_| s.path.clone());
273            path.starts_with(&stored)
274        }) {
275            return;
276        }
277        let display = path.display().to_string();
278        self.project.sources.push(SourceSpec {
279            path,
280            filter: "**/*.png".to_string(),
281        });
282        self.dirty = true;
283        self.pending.pack = true;
284        self.pending.rebuild_watcher = true;
285        self.log_info(t!("state.added_source", path = display));
286    }
287
288    /// Remove the source at `index`.
289    pub fn remove_source(&mut self, index: usize) {
290        if index < self.project.sources.len() {
291            let removed = self.project.sources.remove(index);
292            self.dirty = true;
293            self.pending.pack = true;
294            self.pending.rebuild_watcher = true;
295            self.log_info(t!(
296                "state.removed_source",
297                path = removed.path.display().to_string()
298            ));
299        }
300    }
301}