Skip to main content

fastpack_gui/
app.rs

1use std::sync::mpsc;
2use std::time::Duration;
3
4use eframe::egui;
5use fastpack_compress::{
6    backends::{jpeg::JpegCompressor, png::PngCompressor, webp::WebpCompressor},
7    compressor::{CompressInput, Compressor},
8};
9use fastpack_core::types::{
10    atlas::PackedAtlas,
11    config::DataFormat,
12    pixel_format::{PixelFormat, TextureFormat},
13    rect::Size,
14};
15use fastpack_formats::{
16    exporter::{ExportInput, Exporter},
17    formats::{
18        json_array::JsonArrayExporter, json_hash::JsonHashExporter, phaser3::Phaser3Exporter,
19        pixijs::PixiJsExporter,
20    },
21};
22use notify_debouncer_mini::notify::RecursiveMode;
23use notify_debouncer_mini::{DebounceEventResult, new_debouncer};
24
25use crate::{
26    menu,
27    panels::{anim_preview, atlas_preview, output_log, prefs_window, settings, sprite_list},
28    preferences::Preferences,
29    state::AppState,
30    toolbar,
31    updater::{UpdateMsg, UpdateStatus},
32    worker::{WorkerMessage, run_pack},
33};
34use rust_i18n::t;
35
36/// The root application type that implements `eframe::App`.
37pub struct FastPackApp {
38    /// Shared GUI state and active project data.
39    pub state: AppState,
40    /// Rendered atlas texture handles for the current pack result.
41    pub atlas_textures: Vec<egui::TextureHandle>,
42    worker_rx: Option<mpsc::Receiver<WorkerMessage>>,
43    /// Persistent user preferences loaded from disk.
44    pub prefs: Preferences,
45    prefs_open: bool,
46    update_status: UpdateStatus,
47    update_rx: Option<mpsc::Receiver<UpdateMsg>>,
48    file_watcher: Option<Box<dyn Send>>,
49    watch_rx: Option<mpsc::Receiver<DebounceEventResult>>,
50    /// System DPI pixels-per-point captured at startup; used to apply ui_scale.
51    pub native_pixels_per_point: f32,
52}
53
54impl Default for FastPackApp {
55    fn default() -> Self {
56        let prefs = Preferences::load();
57        rust_i18n::set_locale(prefs.language.code());
58        let state = AppState {
59            dark_mode: prefs.dark_mode,
60            ..AppState::default()
61        };
62        let mut app = Self {
63            state,
64            atlas_textures: Vec::new(),
65            worker_rx: None,
66            prefs,
67            prefs_open: false,
68            update_status: UpdateStatus::Idle,
69            update_rx: None,
70            file_watcher: None,
71            watch_rx: None,
72            native_pixels_per_point: 0.0,
73        };
74        if app.prefs.auto_check_updates {
75            let (tx, rx) = mpsc::channel();
76            crate::updater::spawn_check(tx);
77            app.update_rx = Some(rx);
78            app.update_status = UpdateStatus::Checking;
79        }
80        app
81    }
82}
83
84impl eframe::App for FastPackApp {
85    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
86        crate::theme::apply(ctx, self.state.dark_mode);
87        self.apply_ui_scale(ctx);
88        self.poll_worker(ctx);
89        self.poll_watcher(ctx);
90        self.handle_pending(ctx);
91        self.handle_dropped_files(ctx);
92
93        // Sync dark_mode back to prefs when the toolbar toggles it.
94        if self.prefs.dark_mode != self.state.dark_mode {
95            self.prefs.dark_mode = self.state.dark_mode;
96            self.prefs.save();
97        }
98
99        ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.state.window_title()));
100
101        egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
102            menu::show(ui, &mut self.state, &self.prefs.keybinds);
103        });
104
105        egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
106            toolbar::show(ui, &mut self.state);
107        });
108
109        egui::TopBottomPanel::bottom("output_log")
110            .min_height(80.0)
111            .default_height(100.0)
112            .resizable(true)
113            .show(ctx, |ui| {
114                output_log::show(ui, &mut self.state);
115            });
116
117        egui::SidePanel::left("sprite_list")
118            .min_width(180.0)
119            .default_width(220.0)
120            .resizable(true)
121            .show(ctx, |ui| {
122                sprite_list::show(ui, &mut self.state, &self.atlas_textures);
123            });
124
125        egui::SidePanel::right("settings")
126            .min_width(260.0)
127            .default_width(280.0)
128            .resizable(true)
129            .show(ctx, |ui| {
130                settings::show(ui, &mut self.state);
131            });
132
133        egui::CentralPanel::default().show(ctx, |ui| {
134            atlas_preview::show(ui, &mut self.state, &self.atlas_textures);
135
136            let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
137            if hovering {
138                let overlay_rect = ui.max_rect();
139                ui.painter().rect_filled(
140                    overlay_rect,
141                    0.0,
142                    egui::Color32::from_rgba_unmultiplied(20, 80, 160, 120),
143                );
144                ui.painter().text(
145                    overlay_rect.center(),
146                    egui::Align2::CENTER_CENTER,
147                    t!("drop_overlay"),
148                    egui::FontId::proportional(18.0),
149                    egui::Color32::WHITE,
150                );
151            }
152        });
153
154        if self.prefs_open {
155            prefs_window::show(
156                ctx,
157                &mut self.prefs,
158                &mut self.prefs_open,
159                &mut self.update_status,
160                &mut self.update_rx,
161            );
162        }
163
164        anim_preview::show(ctx, &mut self.state, &self.atlas_textures);
165    }
166}
167
168impl FastPackApp {
169    fn poll_worker(&mut self, ctx: &egui::Context) {
170        let mut finished = false;
171        if let Some(rx) = &self.worker_rx {
172            loop {
173                match rx.try_recv() {
174                    Ok(WorkerMessage::Started) => {
175                        self.state.packing = true;
176                    }
177                    Ok(WorkerMessage::Progress { .. }) => {}
178                    Ok(WorkerMessage::Finished(output)) => {
179                        self.state.packing = false;
180                        self.state.sprite_count = output.sprite_count;
181                        self.state.alias_count = output.alias_count;
182                        self.state.overflow_count = output.overflow_count;
183                        self.state.selected_frames.clear();
184                        self.state.anchor_frame = None;
185                        self.state.anim_preview.open = false;
186                        self.atlas_textures.clear();
187                        self.state.sheets.clear();
188
189                        for (sheet_idx, sheet) in output.sheets.into_iter().enumerate() {
190                            let color_image = egui::ColorImage::from_rgba_unmultiplied(
191                                [sheet.width as usize, sheet.height as usize],
192                                &sheet.rgba,
193                            );
194                            self.atlas_textures.push(ctx.load_texture(
195                                "atlas",
196                                color_image,
197                                egui::TextureOptions::default(),
198                            ));
199                            let frames: Vec<crate::state::FrameInfo> = sheet
200                                .frames
201                                .into_iter()
202                                .map(|f| crate::state::FrameInfo {
203                                    id: f.id,
204                                    sheet_idx,
205                                    x: f.x,
206                                    y: f.y,
207                                    w: f.w,
208                                    h: f.h,
209                                    alias_of: f.alias_of,
210                                })
211                                .collect();
212                            self.state.sheets.push(crate::state::SheetData {
213                                rgba: sheet.rgba,
214                                width: sheet.width,
215                                height: sheet.height,
216                                frames,
217                                atlas_frames: sheet.atlas_frames,
218                            });
219                        }
220
221                        self.state.frames = self
222                            .state
223                            .sheets
224                            .iter()
225                            .flat_map(|s| s.frames.iter().cloned())
226                            .collect();
227                        self.state.frames.sort_unstable_by(|a, b| a.id.cmp(&b.id));
228
229                        let sheet_count = self.state.sheets.len();
230                        let (w, h) = self
231                            .state
232                            .sheets
233                            .first()
234                            .map(|s| (s.width, s.height))
235                            .unwrap_or_default();
236                        self.state.log_info(t!(
237                            "log.pack_result",
238                            sprites = self.state.sprite_count,
239                            w = w,
240                            h = h,
241                            sheets = sheet_count,
242                            plural = if sheet_count == 1 { "" } else { "s" },
243                            aliases = self.state.alias_count,
244                            overflow = self.state.overflow_count,
245                        ));
246                        finished = true;
247                    }
248                    Ok(WorkerMessage::Failed(msg)) => {
249                        self.state.packing = false;
250                        self.state.log_error(t!("log.pack_failed", msg = msg));
251                        finished = true;
252                    }
253                    Err(mpsc::TryRecvError::Empty) => break,
254                    Err(mpsc::TryRecvError::Disconnected) => {
255                        finished = true;
256                        break;
257                    }
258                }
259            }
260        }
261        if finished {
262            self.worker_rx = None;
263        }
264    }
265
266    fn handle_pending(&mut self, ctx: &egui::Context) {
267        if std::mem::take(&mut self.state.pending.pack) {
268            self.spawn_pack(ctx.clone());
269        }
270        if std::mem::take(&mut self.state.pending.export) {
271            self.do_export();
272        }
273        if std::mem::take(&mut self.state.pending.new_project) {
274            self.state.new_project(self.prefs.default_config.clone());
275            self.atlas_textures.clear();
276            self.file_watcher = None;
277            self.watch_rx = None;
278        }
279        if std::mem::take(&mut self.state.pending.open_project) {
280            self.do_open_project();
281            self.state.pending.rebuild_watcher = true;
282        }
283        if std::mem::take(&mut self.state.pending.save_project) {
284            self.do_save_project(false);
285        }
286        if std::mem::take(&mut self.state.pending.save_project_as) {
287            self.do_save_project(true);
288        }
289        if std::mem::take(&mut self.state.pending.add_source) {
290            self.do_add_source();
291        }
292        if std::mem::take(&mut self.state.pending.open_prefs) {
293            self.prefs_open = true;
294        }
295        if std::mem::take(&mut self.state.pending.rebuild_watcher) {
296            self.rebuild_watcher();
297        }
298    }
299
300    fn spawn_pack(&mut self, ctx: egui::Context) {
301        if self.state.packing {
302            return;
303        }
304        if self.state.project.sources.is_empty() {
305            self.state.frames.clear();
306            self.state.sheets.clear();
307            self.atlas_textures.clear();
308            self.state.selected_frames.clear();
309            self.state.anchor_frame = None;
310            self.state.log_warn(t!("log.no_sources"));
311            return;
312        }
313        let (tx, rx) = mpsc::channel();
314        self.worker_rx = Some(rx);
315        let project = self.state.project.clone();
316        std::thread::spawn(move || {
317            tx.send(WorkerMessage::Started).ok();
318            match run_pack(&project) {
319                Ok(output) => {
320                    tx.send(WorkerMessage::Finished(Box::new(output))).ok();
321                }
322                Err(e) => {
323                    tx.send(WorkerMessage::Failed(e.to_string())).ok();
324                }
325            }
326            ctx.request_repaint();
327        });
328    }
329
330    fn do_open_project(&mut self) {
331        let Some(path) = rfd::FileDialog::new()
332            .add_filter("FastPack Project", &["fpsheet"])
333            .pick_file()
334        else {
335            return;
336        };
337        match std::fs::read_to_string(&path) {
338            Ok(text) => match toml::from_str(&text) {
339                Ok(project) => {
340                    self.state.project = project;
341                    self.state.project_path = Some(path.clone());
342                    self.state.dirty = false;
343                    self.state.frames.clear();
344                    self.atlas_textures.clear();
345                    self.state
346                        .log_info(t!("log.opened", path = path.display().to_string()));
347                }
348                Err(e) => self
349                    .state
350                    .log_error(t!("log.parse_failed", err = e.to_string())),
351            },
352            Err(e) => self
353                .state
354                .log_error(t!("log.read_failed", err = e.to_string())),
355        }
356    }
357
358    fn do_save_project(&mut self, force_dialog: bool) {
359        let path = if force_dialog || self.state.project_path.is_none() {
360            rfd::FileDialog::new()
361                .set_file_name("project.fpsheet")
362                .add_filter("FastPack Project", &["fpsheet"])
363                .save_file()
364        } else {
365            self.state.project_path.clone()
366        };
367        let Some(path) = path else { return };
368        match toml::to_string_pretty(&self.state.project) {
369            Ok(text) => match std::fs::write(&path, text.as_bytes()) {
370                Ok(()) => {
371                    self.state.project_path = Some(path.clone());
372                    self.state.dirty = false;
373                    self.state
374                        .log_info(t!("log.saved", path = path.display().to_string()));
375                }
376                Err(e) => self
377                    .state
378                    .log_error(t!("log.write_project_failed", err = e.to_string())),
379            },
380            Err(e) => self
381                .state
382                .log_error(t!("log.serialize_failed", err = e.to_string())),
383        }
384    }
385
386    fn do_add_source(&mut self) {
387        if let Some(paths) = rfd::FileDialog::new().pick_folders() {
388            for path in paths {
389                self.state.add_source_path(path);
390            }
391        }
392    }
393
394    fn do_export(&mut self) {
395        if self.state.sheets.is_empty() {
396            self.state.log_warn(t!("log.nothing_to_export"));
397            return;
398        }
399
400        let out_cfg = &self.state.project.config.output;
401        let out_dir = out_cfg.directory.clone();
402        if out_dir.as_os_str().is_empty() {
403            self.state.log_warn(t!("log.no_output_dir"));
404            return;
405        }
406
407        let texture_format = out_cfg.texture_format;
408        let pixel_format = out_cfg.pixel_format;
409        let quality = out_cfg.quality;
410        let data_format = out_cfg.data_format;
411        let name = out_cfg.name.clone();
412        let pack_mode = self.state.project.config.layout.pack_mode;
413
414        if let Err(e) = std::fs::create_dir_all(&out_dir) {
415            self.state
416                .log_error(t!("log.create_dir_failed", err = e.to_string()));
417            return;
418        }
419
420        let compressor: Box<dyn Compressor> = match texture_format {
421            TextureFormat::Jpeg => Box::new(JpegCompressor),
422            TextureFormat::WebP => Box::new(WebpCompressor),
423            _ => Box::new(PngCompressor),
424        };
425
426        let pixel_format_str = match pixel_format {
427            PixelFormat::Rgba8888 => "RGBA8888",
428            PixelFormat::Rgb888 => "RGB888",
429            PixelFormat::Rgb565 => "RGB565",
430            PixelFormat::Rgba4444 => "RGBA4444",
431            PixelFormat::Rgba5551 => "RGBA5551",
432            PixelFormat::Alpha8 => "ALPHA8",
433        };
434
435        let exporter: Box<dyn Exporter> = match data_format {
436            DataFormat::JsonArray => Box::new(JsonArrayExporter),
437            DataFormat::Phaser3 => Box::new(Phaser3Exporter),
438            DataFormat::Pixijs => Box::new(PixiJsExporter),
439            DataFormat::JsonHash => Box::new(JsonHashExporter),
440        };
441
442        let sheet_base = |i: usize| -> String {
443            if i == 0 {
444                name.clone()
445            } else {
446                format!("{name}{i}")
447            }
448        };
449
450        let tex_ext = compressor.file_extension();
451
452        // Compress textures and build per-sheet metadata.
453        let mut packed_atlases: Vec<PackedAtlas> = Vec::new();
454        let mut tex_filenames: Vec<String> = Vec::new();
455
456        for i in 0..self.state.sheets.len() {
457            let (width, height, rgba, atlas_frames) = {
458                let sheet = &self.state.sheets[i];
459                (
460                    sheet.width,
461                    sheet.height,
462                    sheet.rgba.clone(),
463                    sheet.atlas_frames.clone(),
464                )
465            };
466
467            let atlas_image =
468                image::RgbaImage::from_raw(width, height, rgba).expect("valid rgba buffer");
469            let dyn_image = image::DynamicImage::from(atlas_image);
470
471            let texture_bytes = match compressor.compress(&CompressInput {
472                image: &dyn_image,
473                pack_mode,
474                quality,
475            }) {
476                Ok(output) => output.data,
477                Err(e) => {
478                    self.state
479                        .log_error(t!("log.compress_failed", i = i, err = e.to_string()));
480                    return;
481                }
482            };
483
484            let tex_filename = format!("{}.{}", sheet_base(i), tex_ext);
485            let tex_path = out_dir.join(&tex_filename);
486
487            if let Err(e) = std::fs::write(&tex_path, &texture_bytes) {
488                self.state
489                    .log_error(t!("log.write_texture_failed", i = i, err = e.to_string()));
490                return;
491            }
492
493            let tex_kb = texture_bytes.len() as f64 / 1024.0;
494            self.state.log_info(t!(
495                "log.wrote_texture",
496                path = tex_path.display().to_string(),
497                kb = format!("{:.1}", tex_kb)
498            ));
499
500            tex_filenames.push(tex_filename);
501            packed_atlases.push(PackedAtlas {
502                frames: atlas_frames,
503                size: Size {
504                    w: width,
505                    h: height,
506                },
507                image: None,
508                name: sheet_base(i),
509                scale: 1.0,
510            });
511        }
512
513        // Build export inputs for all sheets.
514        let export_inputs: Vec<ExportInput<'_>> = packed_atlases
515            .iter()
516            .zip(tex_filenames.iter())
517            .map(|(atlas, fname)| ExportInput {
518                atlas,
519                texture_filename: fname.clone(),
520                pixel_format: pixel_format_str.to_string(),
521            })
522            .collect();
523
524        // Try combined output first; fall back to per-sheet.
525        if let Some(result) = exporter.combine(&export_inputs) {
526            match result {
527                Ok(content) => {
528                    let data_filename = format!("{}.{}", name, exporter.file_extension());
529                    let data_path = out_dir.join(&data_filename);
530                    match std::fs::write(&data_path, content.as_bytes()) {
531                        Ok(()) => self.state.log_info(t!(
532                            "log.wrote_data",
533                            path = data_path.display().to_string(),
534                            bytes = content.len(),
535                        )),
536                        Err(e) => self
537                            .state
538                            .log_error(t!("log.write_data_failed", err = e.to_string())),
539                    }
540                }
541                Err(e) => self
542                    .state
543                    .log_error(t!("log.export_failed", err = e.to_string())),
544            }
545        } else {
546            for (i, input) in export_inputs.iter().enumerate() {
547                match exporter.export(input) {
548                    Ok(content) => {
549                        let data_filename =
550                            format!("{}.{}", sheet_base(i), exporter.file_extension());
551                        let data_path = out_dir.join(&data_filename);
552                        match std::fs::write(&data_path, content.as_bytes()) {
553                            Ok(()) => self.state.log_info(t!(
554                                "log.wrote_data",
555                                path = data_path.display().to_string(),
556                                bytes = content.len(),
557                            )),
558                            Err(e) => self
559                                .state
560                                .log_error(t!("log.write_data_failed", err = e.to_string())),
561                        }
562                    }
563                    Err(e) => self.state.log_error(t!(
564                        "log.export_failed_sheet",
565                        i = i,
566                        err = e.to_string()
567                    )),
568                }
569            }
570        }
571    }
572
573    fn handle_dropped_files(&mut self, ctx: &egui::Context) {
574        let dropped = ctx.input(|i| i.raw.dropped_files.clone());
575        let mut new_sources: std::collections::BTreeSet<std::path::PathBuf> =
576            std::collections::BTreeSet::new();
577
578        for file in dropped {
579            let Some(path) = file.path else { continue };
580
581            if path.extension().and_then(|e| e.to_str()) == Some("fpsheet") {
582                match std::fs::read_to_string(&path) {
583                    Ok(text) => match toml::from_str(&text) {
584                        Ok(project) => {
585                            self.state.project = project;
586                            self.state.project_path = Some(path.clone());
587                            self.state.dirty = false;
588                            self.state.frames.clear();
589                            self.atlas_textures.clear();
590                            self.state
591                                .log_info(t!("log.opened", path = path.display().to_string()));
592                        }
593                        Err(e) => self
594                            .state
595                            .log_error(t!("log.parse_failed", err = e.to_string())),
596                    },
597                    Err(e) => self
598                        .state
599                        .log_error(t!("log.read_file_failed", err = e.to_string())),
600                }
601            } else if path.is_dir() {
602                new_sources.insert(std::fs::canonicalize(&path).unwrap_or(path));
603            } else if path.is_file() {
604                if let Some(parent) = path.parent() {
605                    new_sources.insert(
606                        std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
607                    );
608                }
609            }
610        }
611
612        // If both /a and /a/b are in the drop, keep only /a — the walker covers children anyway.
613        let all: Vec<_> = new_sources.iter().cloned().collect();
614        for path in all
615            .iter()
616            .filter(|p| !all.iter().any(|other| other != *p && p.starts_with(other)))
617        {
618            self.state.add_source_path(path.clone());
619        }
620    }
621
622    fn rebuild_watcher(&mut self) {
623        self.file_watcher = None;
624        self.watch_rx = None;
625
626        if self.state.project.sources.is_empty() {
627            return;
628        }
629
630        let (tx, rx) = mpsc::channel::<DebounceEventResult>();
631        match new_debouncer(Duration::from_millis(500), tx) {
632            Ok(mut debouncer) => {
633                let watch_paths: Vec<_> = self
634                    .state
635                    .project
636                    .sources
637                    .iter()
638                    .map(|s| {
639                        if s.path.is_file() {
640                            s.path.parent().unwrap_or(s.path.as_path()).to_path_buf()
641                        } else {
642                            s.path.clone()
643                        }
644                    })
645                    .collect();
646                let mut errors: Vec<String> = Vec::new();
647                for path in &watch_paths {
648                    if let Err(e) = debouncer.watcher().watch(path, RecursiveMode::Recursive) {
649                        errors.push(format!("Could not watch {}: {e}", path.display()));
650                    }
651                }
652                for err in errors {
653                    self.state.log_warn(err);
654                }
655                self.file_watcher = Some(Box::new(debouncer));
656                self.watch_rx = Some(rx);
657            }
658            Err(e) => self
659                .state
660                .log_warn(format!("Could not start file watcher: {e}")),
661        }
662    }
663
664    fn poll_watcher(&mut self, ctx: &egui::Context) {
665        let Some(rx) = &self.watch_rx else { return };
666        let mut changed = false;
667        loop {
668            match rx.try_recv() {
669                Ok(Ok(_)) => changed = true,
670                Ok(Err(_)) | Err(mpsc::TryRecvError::Empty) => break,
671                Err(mpsc::TryRecvError::Disconnected) => break,
672            }
673        }
674        if changed && !self.state.packing {
675            self.state.pending.pack = true;
676            ctx.request_repaint();
677        }
678    }
679
680    fn apply_ui_scale(&mut self, ctx: &egui::Context) {
681        if self.native_pixels_per_point <= 0.0 {
682            self.native_pixels_per_point = ctx.pixels_per_point();
683        }
684        let target = self.native_pixels_per_point * self.prefs.ui_scale;
685        if (ctx.pixels_per_point() - target).abs() > 0.01 {
686            ctx.set_pixels_per_point(target);
687        }
688    }
689}