use std::path::PathBuf; use egui::{ CollapsingHeader, Color32, FontFamily, FontId, Grid, Image, Label, Layout, RichText, util::undoer::Undoer, }; use rpack_cli::{ImageFile, Spritesheet, SpritesheetBuildConfig, SpritesheetError}; use texture_packer::TexturePackerConfig; use crate::helpers::DroppedFileHelper; pub const MY_ACCENT_COLOR32: Color32 = Color32::from_rgb(230, 102, 1); pub const TOP_SIDE_MARGIN: f32 = 10.0; pub const HEADER_HEIGHT: f32 = 45.0; pub const TOP_BUTTON_WIDTH: f32 = 150.0; pub const GIT_HASH: &str = env!("GIT_HASH"); pub struct Application { data: ApplicationData, output: Option, last_error: Option, undoer: Undoer, } #[derive(serde::Deserialize, serde::Serialize, Default, Clone)] pub struct ApplicationData { #[serde(skip, default)] image_data: Vec, #[serde(skip, default)] config: TexturePackerConfig, settings: Settings, } impl PartialEq for ApplicationData { fn eq(&self, other: &Self) -> bool { self.image_data == other.image_data && self.config.allow_rotation == other.config.allow_rotation && self.config.border_padding == other.config.border_padding && self.config.force_max_dimensions == other.config.force_max_dimensions && self.config.max_height == other.config.max_height && self.config.max_width == other.config.max_width && self.config.texture_extrusion == other.config.texture_extrusion && self.config.texture_outlines == other.config.texture_outlines && self.config.texture_padding == other.config.texture_padding && self.config.trim == other.config.trim && self.settings == other.settings } } #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)] pub struct Settings { pub filename: String, pub size: u32, #[serde(skip)] min_size: [u32; 2], pub skip_metadata_serialization: bool, } impl Default for Settings { fn default() -> Self { Self { filename: String::from("Tilemap"), size: 512, min_size: [32, 32], skip_metadata_serialization: false, } } } #[derive(Clone, PartialEq)] pub struct AppImageData { pub width: u32, pub height: u32, pub data: ImageFile, pub path: String, } impl AppImageData { pub fn id(&self) -> &str { self.data.id.as_str() } pub fn update_id(&mut self, prefix: &str) { self.data.id = self .path .strip_prefix(prefix) .unwrap_or(&self.path) .to_owned(); } } impl Default for Application { fn default() -> Self { Self { data: Default::default(), undoer: Default::default(), output: None, last_error: None, } } } impl Application { #[cfg(not(target_arch = "wasm32"))] pub fn read_config(&mut self, config: rpack_cli::TilemapGenerationConfig) { self.data.settings.size = config.size.unwrap_or(512); self.data.config = (&config).into(); let (file_paths, prefix) = config.get_file_paths_and_prefix(); self.data.image_data.clear(); self.data .image_data .extend(file_paths.iter().flat_map(|f| f.create_image(&prefix))); self.rebuild_image_data(); } pub fn get_common_prefix(&self) -> String { let file_paths: Vec = self .data .image_data .iter() .map(|image| image.path.clone()) .collect(); rpack_cli::get_common_prefix(&file_paths) } pub fn rebuild_image_data(&mut self) { let prefix = self.get_common_prefix(); self.data .image_data .iter_mut() .for_each(|f| f.update_id(prefix.as_str())); self.update_min_size(); } pub fn update_min_size(&mut self) { self.data.settings.min_size[0] = self .data .image_data .iter() .max_by(|a, b| a.width.cmp(&b.width)) .map_or(32, |s| s.width); self.data.settings.min_size[1] = self .data .image_data .iter() .max_by(|a, b| a.height.cmp(&b.height)) .map_or(32, |s| s.height); for nr in [32, 64, 128, 256, 512, 1024, 2048, 4096] { if nr >= self.data.settings.min_size[0] && nr >= self.data.settings.min_size[1] { self.data.settings.min_size[0] = nr; self.data.settings.min_size[1] = nr; break; } } } /// Called once before the first frame. #[allow(dead_code, unused_variables, unused_mut)] pub fn new(cc: &eframe::CreationContext<'_>, config_file: Option) -> Self { crate::fonts::setup_custom_fonts(&cc.egui_ctx); // This is also where you can customize the look and feel of egui using // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. egui_extras::install_image_loaders(&cc.egui_ctx); let mut app = Self::default(); #[cfg(not(target_arch = "wasm32"))] if let Some(config_file) = config_file { if let Ok(config) = rpack_cli::TilemapGenerationConfig::read_from_file(&config_file) { app.data.settings.filename = PathBuf::from(config_file) .file_name() .unwrap_or_default() .to_string_lossy() .replace(".rpack_gen.json", ""); app.read_config(config); } } app } fn build_atlas(&mut self, ctx: &egui::Context) { self.last_error = None; self.output = None; ctx.forget_image("bytes://output.png"); if self.data.image_data.is_empty() { return; } let images: Vec<&ImageFile> = self.data.image_data.iter().map(|file| &file.data).collect(); for multiplier in 1..10 { let size = multiplier * self.data.settings.min_size[0]; if size > self.data.settings.size { break; } let config = TexturePackerConfig { max_width: size, max_height: size, ..self.data.config }; match Spritesheet::build( SpritesheetBuildConfig { packer_config: config, skip_metadata_serialization: self.data.settings.skip_metadata_serialization, }, &images, format!("{}.png", &self.data.settings.filename), ) { Ok(data) => { let mut out_vec = vec![]; data.image_data .write_to( &mut std::io::Cursor::new(&mut out_vec), image::ImageFormat::Png, ) .unwrap(); ctx.include_bytes("bytes://output.png", out_vec); self.output = Some(data); break; } Err(e) => { self.last_error = Some(e); } } } if self.output.is_some() { self.last_error = None; } ctx.request_repaint(); } fn save_json(&self) -> Result<(), String> { let Some(spritesheet) = &self.output else { return Err("Data is incorrect".to_owned()); }; let data = spritesheet.atlas_asset_json.to_string(); let filename = format!("{}.rpack.json", self.data.settings.filename); #[cfg(not(target_arch = "wasm32"))] { let path_buf = rfd::FileDialog::new() .set_directory(".") .add_filter(".rpack.json", &["rpack.json"]) .set_file_name(filename) .save_file(); if let Some(path) = path_buf { let write_result = std::fs::write(path, &data); if write_result.is_err() { return Err(format!( "Could not save json atlas, error: {:?}", write_result.unwrap_err() )); } } } #[cfg(target_arch = "wasm32")] { wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() .set_directory(".") .set_file_name(filename) .save_file() .await else { return; }; file.write(data.as_bytes()).await.unwrap(); }); } Ok(()) } fn save_atlas(&self) -> Result<(), String> { let Some(spritesheet) = &self.output else { return Err("Data is incorrect".to_owned()); }; let filename = format!("{}.png", self.data.settings.filename); #[cfg(not(target_arch = "wasm32"))] { let path_buf = rfd::FileDialog::new() .set_directory(".") .add_filter("png", &["png"]) .set_file_name(filename) .save_file(); if let Some(path) = path_buf { let write_result = spritesheet .image_data .save_with_format(path, image::ImageFormat::Png); if write_result.is_err() { return Err(format!( "Could not make atlas, error: {:?}", write_result.unwrap_err() )); } } } #[cfg(target_arch = "wasm32")] { let mut data = vec![]; let Ok(()) = spritesheet.image_data.write_to( &mut std::io::Cursor::new(&mut data), image::ImageFormat::Png, ) else { return Err("Failed to copy data".to_owned()); }; wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() .set_directory(".") .set_file_name(filename) .save_file() .await else { return; }; file.write(&data).await.unwrap(); }); } Ok(()) } } impl eframe::App for Application { /// Called each time the UI needs repainting, which may be many times per second. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.undoer .feed_state(ctx.input(|input| input.time), &self.data); egui::TopBottomPanel::top("topPanel") .frame(egui::Frame::canvas(&ctx.style())) .show(ctx, |ui| { ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { let text = egui::RichText::new("rPack") .font(FontId::new(26.0, FontFamily::Name("semibold".into()))) .color(MY_ACCENT_COLOR32) .strong(); ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, HEADER_HEIGHT)); ui.add(egui::Label::new(text).selectable(false)); }); }); ctx.input(|i| { if i.raw.dropped_files.is_empty() { return; } for file in i.raw.dropped_files.iter() { #[cfg(not(target_arch = "wasm32"))] if let Some(path) = &file.path { if path.is_dir() { let Ok(dir) = path.read_dir() else { continue; }; for entry in dir { if let Ok(entry) = entry { let Ok(metadata) = entry.metadata() else { continue; }; if metadata.is_file() { let Some(dyn_image) = entry.create_image("") else { continue; }; self.data.image_data.push(dyn_image); } } } } else { let Some(path) = &file.path else { continue; }; if path.to_string_lossy().ends_with(".rpack_gen.json") { if let Ok(config) = rpack_cli::TilemapGenerationConfig::read_from_file(&path) { self.data.settings.filename = path .file_name() .unwrap_or_default() .to_string_lossy() .replace(".rpack_gen.json", ""); self.read_config(config); break; } } } } let Some(dyn_image) = file.create_image("") else { continue; }; self.data.image_data.push(dyn_image); } self.output = None; self.rebuild_image_data(); }); egui::TopBottomPanel::bottom("bottom_panel") .frame(egui::Frame::canvas(&ctx.style())) .show(ctx, |ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.add_space(5.0); ui.add_enabled_ui(self.undoer.has_undo(&self.data), |ui| { if ui.button("⮪").on_hover_text("Go back").clicked() { if let Some(action) = self.undoer.undo(&self.data) { self.data = action.clone(); self.rebuild_image_data(); self.build_atlas(ui.ctx()); } } }); ui.add_enabled_ui(self.undoer.has_redo(&self.data), |ui| { if ui.button("⮫").on_hover_text("Redo").clicked() { if let Some(action) = self.undoer.redo(&self.data) { self.data = action.clone(); self.rebuild_image_data(); self.build_atlas(ui.ctx()); } } }); ui.add_space(5.0); powered_by_egui_and_eframe(ui); }); ui.add_space(5.0); }); egui::SidePanel::right("right") .min_width(200.0) .max_width(400.0) .frame(egui::Frame::canvas(&ctx.style())) .show_animated(ctx, !self.data.image_data.is_empty(), |ui| { egui::ScrollArea::vertical() .id_salt("rightPanel_scroll") .show(ui, |ui| { CollapsingHeader::new("Settings") .default_open(true) .show(ui, |ui| { ui.vertical_centered_justified(|ui|{ let label = ui.label("Tilemap filename"); ui.text_edit_singleline(&mut self.data.settings.filename).labelled_by(label.id); ui.add_space(10.0); ui.add( egui::Slider::new(&mut self.data.settings.size, self.data.settings.min_size[0]..=4096) .step_by(32.0) .text("Max size"), ); ui.add( egui::Slider::new(&mut self.data.config.border_padding, 0..=10) .text("Border Padding"), ); ui.add( egui::Slider::new(&mut self.data.config.texture_padding, 0..=10) .text("Texture Padding"), ); // ui.checkbox(&mut self.config.allow_rotation, "Allow Rotation") // .on_hover_text("True to allow rotation of the input images. Default value is `true`. Images rotated will be rotated 90 degrees clockwise."); ui.checkbox(&mut self.data.config.texture_outlines, "Texture Outlines") .on_hover_text("Draw the red line on the edge of the each frames. Useful for debugging."); ui.checkbox(&mut self.data.settings.skip_metadata_serialization, "Skip Metadata Serialization") .on_hover_text("Skip metadata serialization."); // ui.checkbox(&mut self.config.trim, "Trim").on_hover_text("True to trim the empty pixels of the input images."); ui.add_space(10.0); ui.add_enabled_ui(!self.data.image_data.is_empty(), |ui| { if ui .add_sized([TOP_BUTTON_WIDTH, 30.0], egui::Button::new("Build atlas")) .clicked() { self.build_atlas(ctx); } ui.add_space(10.0); }); }); }); ui.separator(); CollapsingHeader::new("Image list") .default_open(true) .show_unindented(ui, |ui| { ui.horizontal(|ui|{ if !self.data.image_data.is_empty() && ui.button("clear list").clicked() { self.data.image_data.clear(); self.output = None; self.update_min_size(); } ui.add_space(10.0); #[cfg(not(target_arch = "wasm32"))] if ui.button("Add").clicked() { if let Some(files) = rfd::FileDialog::new().set_title("Add images").add_filter("Images", &["png", "jpg", "jpeg","dds"]).pick_files(){ for file in files.iter() { let Ok(image) = texture_packer::importer::ImageImporter::import_from_file(file) else { continue }; let id = crate::helpers::id_from_path(&file.to_string_lossy()); self.data.image_data.push(AppImageData { width: image.width(), height: image.height(), data: ImageFile { id: id, image }, path: file.to_string_lossy().to_string() }); } self.rebuild_image_data(); } } }); let mut to_remove: Option = None; let columns = if cfg!(target_arch = "wasm32") { 3 } else { 4 }; Grid::new("Image List").num_columns(columns).striped(true).spacing((10.0,10.0)).show(ui, |ui|{ for (index, file) in self.data.image_data.iter().enumerate() { if ui.button("x").clicked() { to_remove = Some(index); } #[cfg(not(target_arch = "wasm32"))] ui.image(format!("file://{}", file.path.as_str())); ui.add(Label::new(format!("{}x{}", file.width, file.height)).selectable(false)); ui.add(Label::new(file.id()).selectable(false)); ui.end_row(); } }); if let Some(index) = to_remove { self.data.image_data.remove(index); self.output = None; self.rebuild_image_data(); } }); }); }); egui::CentralPanel::default().show(ctx, |ui| { if let Some(error) = &self.last_error { let text = egui::RichText::new(format!("Error: {}", &error)) .font(FontId::new(20.0, FontFamily::Name("semibold".into()))) .color(Color32::RED) .strong(); ui.add(egui::Label::new(text)); } egui::ScrollArea::vertical() .id_salt("vertical_scroll") .show(ui, |ui| { if self.data.image_data.is_empty() { ui.vertical_centered_justified(|ui| { ui.add_space(50.0); ui.add( Label::new( RichText::new("Drop images here first") .heading() .color(MY_ACCENT_COLOR32), ) .selectable(false), ); }); } let Some(data) = &self.output else { return; }; ui.vertical_centered_justified(|ui| { egui::Frame::canvas(&ctx.style()).show(ui, |ui| { ui.add_space(10.0); ui.heading( egui::RichText::new("Created atlas").color(MY_ACCENT_COLOR32), ); ui.add_space(10.0); ui.label(format!( "{} sprites\nsize: {}x{}", data.atlas_asset.frames.len(), data.atlas_asset.size[0], data.atlas_asset.size[1] )); ui.add_space(10.0); ui.horizontal_wrapped(|ui| { let width = (ui.available_width() - 30.0).max(1.0) / 2.0; ui.add_space(10.0); if ui .add_sized([width, 35.0], egui::Button::new("Save atlas image")) .clicked() { if let Err(error) = self.save_atlas() { eprintln!("ERROR: {}", error); } } ui.add_space(10.0); if ui .add_sized([width, 35.0], egui::Button::new("Save atlas json")) .clicked() { if let Err(error) = self.save_json() { eprintln!("ERROR: {}", error); } } ui.add_space(10.0); }); ui.add_space(10.0); CollapsingHeader::new("Atlas JSON") .default_open(true) .show(ui, |ui| { ui.vertical_centered_justified(|ui| { ui.add_space(10.0); egui_json_tree::JsonTree::new( "simple-tree", &data.atlas_asset_json, ) .show(ui); #[cfg(not(target_arch = "wasm32"))] { ui.add_space(10.0); if ui .add(egui::Button::new("Copy JSON to Clipboard")) .clicked() { ui.ctx() .copy_text(data.atlas_asset_json.to_string()); }; } }); }); ui.add_space(10.0); ui.separator(); ui.add(Image::from_uri("bytes://output.png")); ui.separator(); ui.add_space(20.0); }); }); }); }); } } fn powered_by_egui_and_eframe(ui: &mut egui::Ui) { ui.with_layout(Layout::right_to_left(egui::Align::Min), |ui| { ui.add_space(10.0); ui.hyperlink_to(format!("Build: {}", GIT_HASH), env!("CARGO_PKG_HOMEPAGE")); egui::warn_if_debug_build(ui); ui.separator(); egui::widgets::global_theme_preference_switch(ui); ui.separator(); ui.spacing_mut().item_spacing.x = 0.0; ui.hyperlink_to("Mev Lyshkin", "https://www.mevlyshkin.com/"); ui.add_space(10.0); ui.label("Made by "); ui.add_space((ui.available_width() - 10.0).max(15.0)); }); }