UI refactor, some doc tests

This commit is contained in:
Piotr Siuszko 2025-01-08 22:11:05 +01:00
parent b81d3f9c3a
commit 35ae42d2bb
4 changed files with 324 additions and 237 deletions

View File

@ -23,7 +23,7 @@ jobs:
- uses: actions-rs/cargo@v1 - uses: actions-rs/cargo@v1
with: with:
command: check command: check
args: --all-features args: --all-features --lib
check_wasm: check_wasm:
name: Check wasm32 name: Check wasm32
@ -40,23 +40,6 @@ jobs:
with: with:
command: check command: check
args: --all-features --lib --target wasm32-unknown-unknown args: --all-features --lib --target wasm32-unknown-unknown
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
- uses: actions-rs/cargo@v1
with:
command: test
args: --lib
fmt: fmt:
name: Rustfmt name: Rustfmt
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -73,22 +56,6 @@ jobs:
command: fmt command: fmt
args: --all -- --check args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: clippy
- uses: actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings
trunk: trunk:
name: trunk name: trunk
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -54,6 +54,17 @@ impl RpackAtlasAsset {
} }
} }
/// Plugin that provides support for rpack atlases.
///
/// # Example
/// ```no_run
/// use bevy::prelude::*;
/// use bevy_rpack::prelude::*;
///
/// App::new()
/// .add_plugins((DefaultPlugins,RpackAssetPlugin))
/// .run();
/// ```
pub struct RpackAssetPlugin; pub struct RpackAssetPlugin;
impl Plugin for RpackAssetPlugin { impl Plugin for RpackAssetPlugin {

View File

@ -1,5 +1,11 @@
#[cfg(feature = "bevy")] #[cfg(feature = "bevy")]
pub mod bevy; mod bevy;
pub mod prelude {
pub use super::{AtlasAsset,SerializableRect, AtlasFrame};
#[cfg(feature = "bevy")]
pub use super::bevy::{RpackAssetPlugin, RpackAtlasAsset, RpackAtlasAssetError, RpackAtlasAssetLoader};
}
/// Defines a rectangle in pixels with the origin at the top-left of the texture atlas. /// Defines a rectangle in pixels with the origin at the top-left of the texture atlas.
#[derive(Copy, Clone, Debug, serde::Deserialize, serde::Serialize)] #[derive(Copy, Clone, Debug, serde::Deserialize, serde::Serialize)]

View File

@ -1,5 +1,7 @@
use egui::{CollapsingHeader, Color32, DroppedFile, FontFamily, FontId, Image, RichText, Vec2}; use std::{collections::HashMap, path::Path};
use image::DynamicImage;
use egui::{CollapsingHeader, Color32, DroppedFile, FontFamily, FontId, Image, RichText};
use image::{DynamicImage, GenericImageView};
use rpack_cli::{ImageFile, Spritesheet}; use rpack_cli::{ImageFile, Spritesheet};
use texture_packer::{importer::ImageImporter, TexturePackerConfig}; use texture_packer::{importer::ImageImporter, TexturePackerConfig};
pub const MY_ACCENT_COLOR32: Color32 = Color32::from_rgb(230, 102, 1); pub const MY_ACCENT_COLOR32: Color32 = Color32::from_rgb(230, 102, 1);
@ -24,8 +26,13 @@ pub struct TemplateApp {
counter: i32, counter: i32,
#[serde(skip)] #[serde(skip)]
data: Option<Result<Spritesheet, String>>, data: Option<Result<Spritesheet, String>>,
#[serde(skip)]
min_size: [u32; 2],
#[serde(skip)]
max_size: u32,
#[serde(skip)]
image_data: HashMap<String, ImageFile>,
} }
impl Default for TemplateApp { impl Default for TemplateApp {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -36,17 +43,50 @@ impl Default for TemplateApp {
allow_rotation: false, allow_rotation: false,
border_padding: 2, border_padding: 2,
trim: false, trim: false,
force_max_dimensions: true,
..Default::default() ..Default::default()
}, },
counter: 0, counter: 0,
image: None, image: None,
data: None, data: None,
max_size: 4096,
name: String::from("Tilemap"), name: String::from("Tilemap"),
min_size: [32, 32],
image_data: HashMap::new(),
} }
} }
} }
impl TemplateApp { impl TemplateApp {
pub fn rebuild_image_data(&mut self) {
let prefix = Self::get_common_prefix(&self.dropped_files);
self.image_data = self
.dropped_files
.iter()
.flat_map(|f| Self::image_from_dropped_file(f, &prefix))
.collect();
self.update_min_size();
}
pub fn update_min_size(&mut self) {
if let Some(file) = self
.image_data
.values()
.max_by(|a, b| a.image.width().cmp(&b.image.width()))
{
self.min_size[0] = file.image.width();
} else {
self.min_size[0] = 32;
}
if let Some(file) = self
.image_data
.values()
.max_by(|a, b| a.image.height().cmp(&b.image.height()))
{
self.min_size[1] = file.image.height();
} else {
self.min_size[1] = 32;
}
}
/// Called once before the first frame. /// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self { pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
setup_custom_fonts(&cc.egui_ctx); setup_custom_fonts(&cc.egui_ctx);
@ -65,11 +105,19 @@ impl TemplateApp {
fn get_common_prefix(paths: &[DroppedFile]) -> String { fn get_common_prefix(paths: &[DroppedFile]) -> String {
if paths.is_empty() { if paths.is_empty() {
return String::new(); return String::new();
} else if paths.len() == 1 {
let full_name = paths[0].file_path();
let path = Path::new(&full_name)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
return full_name.strip_suffix(&path).unwrap_or_default().to_owned();
} }
let mut prefix = file_path(&paths[0]); let mut prefix = paths[0].file_path();
for s in paths.iter().skip(1) { for s in paths.iter().skip(1) {
let s = file_path(s); let s = s.file_path();
while !s.starts_with(&prefix) { while !s.starts_with(&prefix) {
prefix.pop(); // Remove the last character of the prefix prefix.pop(); // Remove the last character of the prefix
if prefix.is_empty() { if prefix.is_empty() {
@ -80,21 +128,12 @@ impl TemplateApp {
prefix prefix
} }
pub fn image_from_dropped_file<P>(file: &DroppedFile, prefix: P) -> Option<ImageFile> pub fn image_from_dropped_file<P>(file: &DroppedFile, prefix: P) -> Option<(String, ImageFile)>
where where
P: AsRef<str>, P: AsRef<str>,
{ {
let id; let path = file.file_path();
#[cfg(not(target_arch = "wasm32"))] let base_id = path.replace(".png", "");
{
let path = file.path.as_ref().unwrap().clone();
id = path.to_str().unwrap().to_owned();
}
#[cfg(target_arch = "wasm32")]
{
id = file.name.clone();
}
let base_id = id.replace(".png", "");
let id = base_id let id = base_id
.strip_prefix(prefix.as_ref()) .strip_prefix(prefix.as_ref())
@ -102,83 +141,94 @@ impl TemplateApp {
.to_owned() .to_owned()
.replace("\\", "/"); .replace("\\", "/");
let image = dynamic_image_from_file(file)?; let image: DynamicImage = dynamic_image_from_file(file)?;
Some(ImageFile { id, image }) Some((path, ImageFile { id, image }))
} }
fn build_atlas(&mut self, ctx: &egui::Context) { fn build_atlas(&mut self, ctx: &egui::Context) {
let prefix = Self::get_common_prefix(&self.dropped_files); self.data = None;
println!("Prefix: {}", prefix); self.image = None;
let images: Vec<ImageFile> = self let images: Vec<ImageFile> = self.image_data.values().cloned().collect();
.dropped_files
.iter()
.flat_map(|f| Self::image_from_dropped_file(f, &prefix))
.collect();
self.data = Some(Spritesheet::build(self.config, &images, "name")); for size in [32, 64, 128, 256, 512, 1024, 2048, 4096] {
if let Some(Ok(data)) = &self.data { if size < self.min_size[0] || size < self.min_size[1] {
let mut out_vec = vec![]; continue;
let mut img = }
image::DynamicImage::new_rgba8(data.atlas_asset.size[0], data.atlas_asset.size[1]); if size > self.max_size {
image::imageops::overlay(&mut img, &data.image_data, 0, 0); break;
}
img.write_to( let config = TexturePackerConfig {
&mut std::io::Cursor::new(&mut out_vec), max_width: size,
image::ImageFormat::Png, max_height: size,
) ..self.config
.unwrap(); };
ctx.include_bytes("bytes://output.png", out_vec); self.data = Some(Spritesheet::build(
self.image = config,
Some(Image::from_uri("bytes://output.png").max_size(Vec2::new(256.0, 256.0))); &images,
format!("{}.png", &self.name),
));
if let Some(Ok(data)) = &self.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.image = Some(Image::from_uri("bytes://output.png"));
break;
}
} }
ctx.request_repaint(); ctx.request_repaint();
} }
fn save_atlas(&mut self) { fn save_atlas(&self) -> Result<(), String> {
let Some(Ok(data)) = &self.data else { let Some(Ok(spritesheet)) = &self.data else {
return; return Err("Data is incorrect".to_owned());
}; };
let data = data.image_data.as_bytes().to_vec();
let filename = format!("{}.png", self.name); let filename = format!("{}.png", self.name);
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
use std::io::Write;
let path_buf = rfd::FileDialog::new() let path_buf = rfd::FileDialog::new()
.set_directory(".") .set_directory(".")
.add_filter("Image", &["png"]) .add_filter("png", &["png"])
.set_file_name(filename) .set_file_name(filename)
.save_file(); .save_file();
if let Some(path) = path_buf { if let Some(path) = path_buf {
let mut file = std::fs::File::create(path).unwrap(); let write_result = spritesheet
let write_result = file.write_all(&data); .image_data
.save_with_format(path, image::ImageFormat::Png);
if write_result.is_err() { if write_result.is_err() {
self.data = Some(Err(format!( return Err(format!(
"Could not make atlas, error: {:?}", "Could not make atlas, error: {:?}",
write_result.unwrap_err() write_result.unwrap_err()
))); ));
} else {
println!("Output texture stored in {:?}", file);
} }
} }
} }
#[cfg(target_arch = "wasm32")] #[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 { wasm_bindgen_futures::spawn_local(async move {
let file = rfd::AsyncFileDialog::new() let Some(file) = rfd::AsyncFileDialog::new()
.set_directory(".") .set_directory(".")
.set_file_name(filename) .set_file_name(filename)
.save_file() .save_file()
.await; .await
match file { else {
None => (), return;
Some(file) => { };
// let module = serde_yaml::to_string(&module).unwrap(); file.write(&data).await.unwrap();
// TODO: error handling
file.write(&data).await.unwrap();
}
}
}); });
} }
Ok(())
} }
} }
@ -235,49 +285,21 @@ impl eframe::App for TemplateApp {
egui::TopBottomPanel::top("topPanel") egui::TopBottomPanel::top("topPanel")
.frame(egui::Frame::canvas(&ctx.style())) .frame(egui::Frame::canvas(&ctx.style()))
.show(ctx, |ui| { .show(ctx, |ui| {
ui.with_layout( ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
egui::Layout::left_to_right(egui::Align::Center) let text = egui::RichText::new("rPack")
.with_cross_align(eframe::emath::Align::Center), .font(FontId::new(26.0, FontFamily::Name("semibold".into())))
|ui| { .color(MY_ACCENT_COLOR32)
let text = egui::RichText::new("rPack") .strong();
.font(FontId::new(26.0, FontFamily::Name("semibold".into()))) ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, HEADER_HEIGHT));
.color(MY_ACCENT_COLOR32) ui.add(egui::Label::new(text));
.strong(); });
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, HEADER_HEIGHT));
ui.add(egui::Label::new(text));
let available_width =
ui.available_width() - ((TOP_BUTTON_WIDTH - TOP_SIDE_MARGIN) * 3.0);
ui.allocate_space(egui::vec2(available_width, HEADER_HEIGHT));
ui.add_enabled_ui(self.data.is_some(), |ui| {
if ui
.add_sized([TOP_BUTTON_WIDTH, 30.0], egui::Button::new("Save"))
.clicked()
{
self.save_atlas();
}
});
ui.add_enabled_ui(!self.dropped_files.is_empty(), |ui| {
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, 10.0));
if ui
.add_sized(
[TOP_BUTTON_WIDTH, 30.0],
egui::Button::new("Build atlas"),
)
.clicked()
{
self.image = None;
ctx.forget_image("bytes://output.png");
self.build_atlas(ctx);
}
});
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, 10.0));
},
);
}); });
ctx.input(|i| { ctx.input(|i| {
if !i.raw.dropped_files.is_empty() { if !i.raw.dropped_files.is_empty() {
let mut extra = i.raw.dropped_files.clone(); let mut extra = i.raw.dropped_files.clone();
self.dropped_files.append(&mut extra); self.dropped_files.append(&mut extra);
self.data = None;
self.rebuild_image_data();
} }
}); });
egui::TopBottomPanel::bottom("bottom_panel") egui::TopBottomPanel::bottom("bottom_panel")
@ -285,116 +307,197 @@ impl eframe::App for TemplateApp {
.show(ctx, |ui| { .show(ctx, |ui| {
powered_by_egui_and_eframe(ui); powered_by_egui_and_eframe(ui);
}); });
egui::SidePanel::right("leftPanel")
egui::CentralPanel::default().show(ctx, |ui| { .min_width(200.0)
if let Some(Err(error)) = &self.data { .frame(egui::Frame::canvas(&ctx.style()))
let text = egui::RichText::new(format!("Error: {}",&error)) .show_animated(ctx, !self.image_data.is_empty(), |ui| {
.font(FontId::new(20.0, FontFamily::Name("semibold".into()))) CollapsingHeader::new("Settings")
.color(Color32::RED) .default_open(true)
.strong(); .show(ui, |ui| {
ui.add(egui::Label::new(text)); ui.vertical_centered_justified(|ui|{
} let label = ui.label("Tilemap filename");
if !self.dropped_files.is_empty() { ui.text_edit_singleline(&mut self.name).labelled_by(label.id);
ui.horizontal_top(|ui|{ ui.add_space(10.0);
if let Some(image) = &self.image { ui.add(
ui.add(image.clone()); egui::Slider::new(&mut self.max_size, self.min_size[0]..=4096)
} .step_by(32.0)
CollapsingHeader::new("Settings") .text("Max size"),
.default_open(false) );
.show(ui, |ui| { ui.add(
ui.label("Tilemap id"); egui::Slider::new(&mut self.config.border_padding, 0..=10)
ui.text_edit_singleline(&mut self.name); .text("Border Padding"),
ui.add( );
egui::Slider::new(&mut self.config.max_width, 64..=4096).text("Width"), ui.add(
); egui::Slider::new(&mut self.config.texture_padding, 0..=10)
ui.add( .text("Texture Padding"),
egui::Slider::new(&mut self.config.max_height, 64..=4096).text("Height"), );
); // ui.checkbox(&mut self.config.allow_rotation, "Allow Rotation")
ui.add( // .on_hover_text("True to allow rotation of the input images. Default value is `true`. Images rotated will be rotated 90 degrees clockwise.");
egui::Slider::new(&mut self.config.border_padding, 0..=10).text("Border Padding"), ui.checkbox(&mut self.config.texture_outlines, "Texture Outlines")
); .on_hover_text("Draw the red line on the edge of the each frames. Useful for debugging.");
ui.add( // ui.checkbox(&mut self.config.trim, "Trim").on_hover_text("True to trim the empty pixels of the input images.");
egui::Slider::new(&mut self.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.config.texture_outlines, "Texture Outlines")
.on_hover_text("True to draw the red line on the edge of the each frames. Useful for debugging.");
ui.checkbox(&mut self.config.trim, "Trim").on_hover_text("True to trim the empty pixels of the input images.");
});
});
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui|{
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
if let Some(Ok(data)) = &self.data {
ui.horizontal_top(|ui|{
ui.label(format!("{} frames, size: {}x{}",data.atlas_asset.frames.len(),data.atlas_asset.size[0],data.atlas_asset.size[1]));
});
ui.label(RichText::new("Frames JSON").strong());
egui_json_tree::JsonTree::new("simple-tree", &data.atlas_asset_json).show(ui);
if ui
.add(egui::Button::new("Copy JSON to Clipboard"))
.clicked()
{
ui.output_mut(|o| o.copied_text = data.atlas_asset_json.to_string());
};
}
ui.separator();
let mut index_to_remove : Option<usize> = None;
for (i, file) in self.dropped_files.iter().enumerate() {
let mut info = if let Some(path) = &file.path {
path.display().to_string()
} else if !file.name.is_empty() {
file.name.clone()
} else {
"???".to_owned()
};
if let Some(bytes) = &file.bytes {
info += &format!(" ({} bytes)", bytes.len());
}
ui.horizontal_top(|ui|{
if ui.button("x").clicked(){
index_to_remove = Some(i);
}
ui.add_space(10.0); ui.add_space(10.0);
ui.label(info);
ui.add_enabled_ui(!self.dropped_files.is_empty(), |ui| {
if ui
.add_sized([TOP_BUTTON_WIDTH, 30.0], egui::Button::new("Build atlas"))
.clicked()
{
self.image = None;
ctx.forget_image("bytes://output.png");
self.build_atlas(ctx);
}
ui.add_space(10.0);
});
});
});
ui.separator();
CollapsingHeader::new("Image list")
.default_open(true)
.show(ui, |ui| {
if !self.image_data.is_empty() && ui.button("clear list").clicked() {
self.image_data.clear();
self.dropped_files.clear();
self.data = None;
self.update_min_size();
}
let mut to_remove: Option<String> = None;
for (id, file) in self.image_data.iter() {
ui.horizontal_top(|ui| {
ui.add_space(10.0);
if ui.button("x").clicked() {
to_remove = Some(id.clone());
}
ui.add_space(10.0);
let (x, y) = file.image.dimensions();
ui.label(&file.id)
.on_hover_text(format!("Dimensions: {}x{}", x, y));
});
}
if let Some(index) = to_remove {
if let Some(i) = self
.dropped_files
.iter()
.position(|e| e.file_path().eq(&index))
{
self.dropped_files.remove(i);
self.image_data.remove(&index);
self.data = None;
self.rebuild_image_data();
}
}
});
});
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical()
.id_salt("vertical_scroll")
.show(ui, |ui| {
if let Some(Err(error)) = &self.data {
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));
return;
}
if self.dropped_files.is_empty() {
ui.vertical_centered_justified(|ui| {
ui.add_space(50.0);
ui.label(
RichText::new("Drop files here")
.heading()
.color(MY_ACCENT_COLOR32),
);
}); });
} }
if let Some(index) = index_to_remove{ let Some(image) = &self.image else {
self.dropped_files.remove(index); return;
} };
let Some(Ok(data)) = &self.data 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("Crated 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.add_enabled_ui(self.data.is_some(), |ui| {
if ui
.add_sized(
[TOP_BUTTON_WIDTH, 30.0],
egui::Button::new("Save atlas image"),
)
.clicked()
{
if let Err(error) = self.save_atlas() {
eprintln!("ERROR: {}", error);
}
}
});
ui.add_space(10.0);
CollapsingHeader::new("Atlas JSON")
.default_open(true)
.show(ui, |ui| {
ui.vertical_centered_justified(|ui| {
if ui
.add(egui::Button::new("Copy JSON to Clipboard"))
.clicked()
{
ui.output_mut(|o| {
o.copied_text = data.atlas_asset_json.to_string()
});
};
ui.add_space(10.0);
ui.label(RichText::new("Frames JSON").strong());
ui.add_space(10.0);
egui_json_tree::JsonTree::new(
"simple-tree",
&data.atlas_asset_json,
)
.show(ui);
});
});
ui.add_space(10.0);
ui.separator();
ui.add(image.clone());
ui.separator();
ui.add_space(10.0);
ui.add_space(10.0);
});
});
}); });
if ui.button("clear list").clicked() {
self.dropped_files.clear();
}
});
} else {
ui.vertical_centered_justified(|ui|{
ui.add_space(50.0);
ui.label(
RichText::new("Drop files here")
.heading()
.color(MY_ACCENT_COLOR32),
);
});
}
}); });
} }
} }
fn file_path(file: &DroppedFile) -> String { trait FilePath {
let id; fn file_path(&self) -> String;
#[cfg(not(target_arch = "wasm32"))] }
{
let path = file.path.as_ref().unwrap().clone(); impl FilePath for DroppedFile {
id = path.to_str().unwrap().to_owned(); fn file_path(&self) -> String {
let id;
#[cfg(not(target_arch = "wasm32"))]
{
let path = self.path.as_ref().unwrap().clone();
id = path.to_str().unwrap().to_owned();
}
#[cfg(target_arch = "wasm32")]
{
id = self.name.clone();
}
id.replace(".png", "")
} }
#[cfg(target_arch = "wasm32")]
{
id = file.name.clone();
}
id.replace(".png", "")
} }
fn dynamic_image_from_file(file: &DroppedFile) -> Option<DynamicImage> { fn dynamic_image_from_file(file: &DroppedFile) -> Option<DynamicImage> {