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
36pub struct FastPackApp {
38 pub state: AppState,
40 pub atlas_textures: Vec<egui::TextureHandle>,
42 worker_rx: Option<mpsc::Receiver<WorkerMessage>>,
43 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 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 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 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 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 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 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}