diff --git a/src/anm2/anm2.h b/src/anm2/anm2.h index 0bbe11c..e931450 100644 --- a/src/anm2/anm2.h +++ b/src/anm2/anm2.h @@ -39,7 +39,7 @@ namespace anm2ed::anm2 Spritesheet* spritesheet_get(int); bool spritesheet_add(const std::filesystem::path&, const std::filesystem::path&, int&); - bool spritesheet_pack(int); + bool spritesheet_pack(int, int); bool regions_trim(int, const std::set&); std::vector spritesheet_labels_get(); std::vector spritesheet_ids_get(); diff --git a/src/anm2/anm2_spritesheets.cpp b/src/anm2/anm2_spritesheets.cpp index e4aa2f3..45d1fff 100644 --- a/src/anm2/anm2_spritesheets.cpp +++ b/src/anm2/anm2_spritesheets.cpp @@ -29,9 +29,9 @@ namespace anm2ed::anm2 return true; } - bool Anm2::spritesheet_pack(int id) + bool Anm2::spritesheet_pack(int id, int padding) { - constexpr int PACKING_PADDING = 1; + const int packingPadding = std::max(0, padding); struct RectI { @@ -256,8 +256,8 @@ namespace anm2ed::anm2 auto minPoint = glm::ivec2(glm::min(region.crop, region.crop + region.size)); auto maxPoint = glm::ivec2(glm::max(region.crop, region.crop + region.size)); auto size = glm::max(maxPoint - minPoint, glm::ivec2(1)); - int packWidth = size.x + PACKING_PADDING * 2; - int packHeight = size.y + PACKING_PADDING * 2; + int packWidth = size.x + packingPadding * 2; + int packHeight = size.y + packingPadding * 2; items.push_back({regionID, minPoint.x, minPoint.y, size.x, size.y, packWidth, packHeight}); } @@ -290,8 +290,8 @@ namespace anm2ed::anm2 { int sourceX = item.srcX + x; int sourceY = item.srcY + y; - int destinationX = destinationRect.x + PACKING_PADDING + x; - int destinationY = destinationRect.y + PACKING_PADDING + y; + int destinationX = destinationRect.x + packingPadding + x; + int destinationY = destinationRect.y + packingPadding + y; if (sourceX < 0 || sourceY < 0 || sourceX >= textureSize.x || sourceY >= textureSize.y) continue; if (destinationX < 0 || destinationY < 0 || destinationX >= packedWidth || destinationY >= packedHeight) @@ -312,7 +312,7 @@ namespace anm2ed::anm2 if (packedRects.contains(regionID)) { auto& rect = packedRects.at(regionID); - region.crop = {rect.x + PACKING_PADDING, rect.y + PACKING_PADDING}; + region.crop = {rect.x + packingPadding, rect.y + packingPadding}; } return true; diff --git a/src/anm2/spritesheet.cpp b/src/anm2/spritesheet.cpp index bd717a8..6637be6 100644 --- a/src/anm2/spritesheet.cpp +++ b/src/anm2/spritesheet.cpp @@ -1,7 +1,9 @@ #include "spritesheet.h" #include +#include #include +#include #include #include "map_.h" @@ -52,6 +54,7 @@ namespace anm2ed::anm2 path = path::lower_case_backslash_handle(path); texture = Texture(path); + regionOrder.clear(); for (auto child = element->FirstChildElement("Region"); child; child = child->NextSiblingElement("Region")) { Region region{}; @@ -73,12 +76,16 @@ namespace anm2ed::anm2 child->QueryFloatAttribute("YPivot", ®ion.pivot.y); } regions.emplace(id, std::move(region)); + regionOrder.push_back(id); } - regionOrder.clear(); - regionOrder.reserve(regions.size()); - for (auto id : regions | std::views::keys) - regionOrder.push_back(id); + if (regionOrder.size() != regions.size()) + { + regionOrder.clear(); + regionOrder.reserve(regions.size()); + for (auto id : regions | std::views::keys) + regionOrder.push_back(id); + } } Spritesheet::Spritesheet(const std::filesystem::path& directory, const std::filesystem::path& path) @@ -231,4 +238,43 @@ namespace anm2ed::anm2 } bool Spritesheet::is_valid() { return texture.is_valid(); } + uint64_t Spritesheet::hash() const + { + auto hash_combine = [](std::size_t& seed, std::size_t value) + { + seed ^= value + 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); + }; + + std::size_t seed{}; + hash_combine(seed, std::hash{}(texture.size.x)); + hash_combine(seed, std::hash{}(texture.size.y)); + hash_combine(seed, std::hash{}(texture.channels)); + hash_combine(seed, std::hash{}(texture.filter)); + + if (!texture.pixels.empty()) + { + std::string_view bytes(reinterpret_cast(texture.pixels.data()), texture.pixels.size()); + hash_combine(seed, std::hash{}(bytes)); + } + else + { + hash_combine(seed, 0); + } + + for (const auto& [id, region] : regions) + { + hash_combine(seed, std::hash{}(id)); + hash_combine(seed, std::hash{}(region.name)); + hash_combine(seed, std::hash{}(region.crop.x)); + hash_combine(seed, std::hash{}(region.crop.y)); + hash_combine(seed, std::hash{}(region.size.x)); + hash_combine(seed, std::hash{}(region.size.y)); + hash_combine(seed, std::hash{}(region.pivot.x)); + hash_combine(seed, std::hash{}(region.pivot.y)); + hash_combine(seed, std::hash{}(static_cast(region.origin))); + } + + return static_cast(seed); + } + } diff --git a/src/anm2/spritesheet.h b/src/anm2/spritesheet.h index 5805bee..b51ecf3 100644 --- a/src/anm2/spritesheet.h +++ b/src/anm2/spritesheet.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "texture.h" @@ -48,5 +49,6 @@ namespace anm2ed::anm2 void serialize(tinyxml2::XMLDocument&, tinyxml2::XMLElement*, int, Flags = 0); void reload(const std::filesystem::path&, const std::filesystem::path& = {}); bool is_valid(); + uint64_t hash() const; }; } diff --git a/src/document.cpp b/src/document.cpp index 9159558..fd280f8 100644 --- a/src/document.cpp +++ b/src/document.cpp @@ -75,8 +75,10 @@ namespace anm2ed previewZoom(other.previewZoom), previewPan(other.previewPan), editorPan(other.editorPan), editorZoom(other.editorZoom), overlayIndex(other.overlayIndex), hash(other.hash), saveHash(other.saveHash), autosaveHash(other.autosaveHash), lastAutosaveTime(other.lastAutosaveTime), isValid(other.isValid), - isOpen(other.isOpen), isForceDirty(other.isForceDirty), isAnimationPreviewSet(other.isAnimationPreviewSet), - isSpritesheetEditorSet(other.isSpritesheetEditorSet) + isOpen(other.isOpen), isForceDirty(other.isForceDirty), + spritesheetHashes(std::move(other.spritesheetHashes)), + spritesheetSaveHashes(std::move(other.spritesheetSaveHashes)), + isAnimationPreviewSet(other.isAnimationPreviewSet), isSpritesheetEditorSet(other.isSpritesheetEditorSet) { } @@ -99,6 +101,8 @@ namespace anm2ed isValid = other.isValid; isOpen = other.isOpen; isForceDirty = other.isForceDirty; + spritesheetHashes = std::move(other.spritesheetHashes); + spritesheetSaveHashes = std::move(other.spritesheetSaveHashes); isAnimationPreviewSet = other.isAnimationPreviewSet; isSpritesheetEditorSet = other.isSpritesheetEditorSet; } @@ -182,6 +186,44 @@ namespace anm2ed isForceDirty = false; } + void Document::spritesheet_hashes_reset() + { + spritesheetHashes.clear(); + spritesheetSaveHashes.clear(); + for (auto& [id, spritesheet] : anm2.content.spritesheets) + { + auto currentHash = spritesheet.hash(); + spritesheetHashes[id] = currentHash; + spritesheetSaveHashes[id] = currentHash; + } + } + + void Document::spritesheet_hashes_sync() + { + for (auto it = spritesheetHashes.begin(); it != spritesheetHashes.end();) + { + if (!anm2.content.spritesheets.contains(it->first)) + it = spritesheetHashes.erase(it); + else + ++it; + } + + for (auto it = spritesheetSaveHashes.begin(); it != spritesheetSaveHashes.end();) + { + if (!anm2.content.spritesheets.contains(it->first)) + it = spritesheetSaveHashes.erase(it); + else + ++it; + } + + for (auto& [id, spritesheet] : anm2.content.spritesheets) + { + auto currentHash = spritesheet.hash(); + spritesheetHashes[id] = currentHash; + if (!spritesheetSaveHashes.contains(id)) spritesheetSaveHashes[id] = currentHash; + } + } + void Document::change(ChangeType type) { hash_set(); @@ -191,7 +233,10 @@ namespace anm2ed auto animations_set = [&]() { animation.labels_set(anm2.animation_labels_get()); }; auto spritesheets_set = [&]() - { spritesheet.labels_set(anm2.spritesheet_labels_get(), anm2.spritesheet_ids_get()); }; + { + spritesheet.labels_set(anm2.spritesheet_labels_get(), anm2.spritesheet_ids_get()); + spritesheet_hashes_sync(); + }; auto sounds_set = [&]() { sound.labels_set(anm2.sound_labels_get(), anm2.sound_ids_get()); }; @@ -244,6 +289,37 @@ namespace anm2ed bool Document::is_dirty() const { return hash != saveHash; } bool Document::is_autosave_dirty() const { return hash != autosaveHash; } + void Document::spritesheet_hash_update(int id) + { + if (!anm2.content.spritesheets.contains(id)) return; + spritesheetHashes[id] = anm2.content.spritesheets.at(id).hash(); + } + + void Document::spritesheet_hash_set_saved(int id) + { + if (!anm2.content.spritesheets.contains(id)) return; + auto currentHash = anm2.content.spritesheets.at(id).hash(); + spritesheetHashes[id] = currentHash; + spritesheetSaveHashes[id] = currentHash; + } + + bool Document::spritesheet_is_dirty(int id) + { + if (!anm2.content.spritesheets.contains(id)) return false; + if (!spritesheetHashes.contains(id)) spritesheet_hash_update(id); + auto saveIt = spritesheetSaveHashes.find(id); + if (saveIt == spritesheetSaveHashes.end()) return false; + return spritesheetHashes.at(id) != saveIt->second; + } + + bool Document::spritesheet_any_dirty() + { + for (auto& [id, spritesheet] : anm2.content.spritesheets) + { + if (spritesheet_is_dirty(id)) return true; + } + return false; + } std::filesystem::path Document::directory_get() const { return path.parent_path(); } std::filesystem::path Document::filename_get() const { return path.filename(); } bool Document::is_valid() const { return isValid && !path.empty(); } @@ -272,6 +348,7 @@ namespace anm2ed auto pathString = path::to_utf8(spritesheet.path); this->spritesheet.selection = {id}; this->spritesheet.reference = id; + spritesheet_hash_set_saved(id); toasts.push(std::vformat(localize.get(TOAST_SPRITESHEET_INITIALIZED), std::make_format_args(id, pathString))); logger.info(std::vformat(localize.get(TOAST_SPRITESHEET_INITIALIZED, anm2ed::ENGLISH), std::make_format_args(id, pathString))); diff --git a/src/document.h b/src/document.h index dd0f91d..530a14e 100644 --- a/src/document.h +++ b/src/document.h @@ -2,6 +2,7 @@ #include #include +#include #include "snapshots.h" @@ -64,6 +65,8 @@ namespace anm2ed bool isValid{true}; bool isOpen{true}; bool isForceDirty{false}; + std::unordered_map spritesheetHashes{}; + std::unordered_map spritesheetSaveHashes{}; bool isAnimationPreviewSet{false}; bool isSpritesheetEditorSet{false}; @@ -82,6 +85,12 @@ namespace anm2ed std::filesystem::path directory_get() const; std::filesystem::path filename_get() const; bool is_valid() const; + void spritesheet_hash_update(int); + void spritesheet_hash_set_saved(int); + bool spritesheet_is_dirty(int); + bool spritesheet_any_dirty(); + void spritesheet_hashes_reset(); + void spritesheet_hashes_sync(); anm2::Frame* frame_get(); anm2::Item* item_get(); diff --git a/src/imgui/documents.cpp b/src/imgui/documents.cpp index 33e22f1..cdd442f 100644 --- a/src/imgui/documents.cpp +++ b/src/imgui/documents.cpp @@ -6,6 +6,8 @@ #include "path_.h" #include "strings.h" #include "time_.h" +#include "toast.h" +#include "log.h" using namespace anm2ed::resource; using namespace anm2ed::types; @@ -61,7 +63,9 @@ namespace anm2ed::imgui for (int i = 0; i < documentsCount; ++i) { auto& document = manager.documents[i]; - auto isDirty = document.is_dirty() || document.isForceDirty; + auto isDocumentDirty = document.is_dirty() || document.isForceDirty; + auto isSpritesheetDirty = document.spritesheet_any_dirty(); + auto isDirty = isDocumentDirty || isSpritesheetDirty; if (!closePopup.is_open()) { @@ -87,13 +91,13 @@ namespace anm2ed::imgui } auto isRequested = i == manager.pendingSelected; - auto font = isDirty ? font::ITALICS : font::REGULAR; + auto font = isDocumentDirty ? font::ITALICS : font::REGULAR; auto filename = path::to_utf8(document.filename_get()); auto string = - isDirty ? std::vformat(localize.get(FORMAT_NOT_SAVED), std::make_format_args(filename)) : filename; + isDocumentDirty ? std::vformat(localize.get(FORMAT_NOT_SAVED), std::make_format_args(filename)) : filename; auto label = std::format("{}###Document{}", string, i); - auto flags = isDirty ? ImGuiTabItemFlags_UnsavedDocument : 0; + auto flags = isDocumentDirty ? ImGuiTabItemFlags_UnsavedDocument : 0; if (isRequested) flags |= ImGuiTabItemFlags_SetSelected; ImGui::PushFont(resources.fonts[font].get(), font::SIZE); @@ -129,7 +133,13 @@ namespace anm2ed::imgui auto& closeDocument = manager.documents[closeDocumentIndex]; auto filename = path::to_utf8(closeDocument.filename_get()); - auto prompt = std::vformat(localize.get(LABEL_DOCUMENT_MODIFIED_PROMPT), std::make_format_args(filename)); + auto isDocumentDirty = closeDocument.is_dirty() || closeDocument.isForceDirty; + auto isSpritesheetDirty = closeDocument.spritesheet_any_dirty(); + auto promptLabel = isDocumentDirty && isSpritesheetDirty + ? LABEL_DOCUMENT_AND_SPRITESHEETS_MODIFIED_PROMPT + : (isDocumentDirty ? LABEL_DOCUMENT_MODIFIED_PROMPT + : LABEL_SPRITESHEETS_MODIFIED_PROMPT); + auto prompt = std::vformat(localize.get(promptLabel), std::make_format_args(filename)); ImGui::TextUnformatted(prompt.c_str()); auto widgetSize = imgui::widget_size_with_row_get(3); @@ -143,7 +153,31 @@ namespace anm2ed::imgui shortcut(manager.chords[SHORTCUT_CONFIRM]); if (ImGui::Button(localize.get(BASIC_YES), widgetSize)) { - manager.save(closeDocumentIndex, {}, (anm2::Compatibility)settings.fileCompatibility); + if (isDocumentDirty) + manager.save(closeDocumentIndex, {}, (anm2::Compatibility)settings.fileCompatibility); + + if (isSpritesheetDirty) + { + for (auto& [id, spritesheet] : closeDocument.anm2.content.spritesheets) + { + if (!closeDocument.spritesheet_is_dirty(id)) continue; + auto pathString = path::to_utf8(spritesheet.path); + if (spritesheet.save(closeDocument.directory_get())) + { + closeDocument.spritesheet_hash_set_saved(id); + toasts.push(std::vformat(localize.get(TOAST_SAVE_SPRITESHEET), std::make_format_args(id, pathString))); + logger.info(std::vformat(localize.get(TOAST_SAVE_SPRITESHEET, anm2ed::ENGLISH), + std::make_format_args(id, pathString))); + } + else + { + toasts.push( + std::vformat(localize.get(TOAST_SAVE_SPRITESHEET_FAILED), std::make_format_args(id, pathString))); + logger.error(std::vformat(localize.get(TOAST_SAVE_SPRITESHEET_FAILED, anm2ed::ENGLISH), + std::make_format_args(id, pathString))); + } + } + } manager.close(closeDocumentIndex); close(); } diff --git a/src/imgui/window/regions.cpp b/src/imgui/window/regions.cpp index f8803d4..8be4840 100644 --- a/src/imgui/window/regions.cpp +++ b/src/imgui/window/regions.cpp @@ -224,6 +224,41 @@ namespace anm2ed::imgui selection.insert(id); } if (ImGui::Shortcut(ImGuiKey_Escape, ImGuiInputFlags_RouteFocused)) selection.clear(); + auto scroll_to_item = [&](float itemHeight, bool isTarget) + { + if (!isTarget) return; + auto windowHeight = ImGui::GetWindowHeight(); + auto targetTop = ImGui::GetCursorPosY(); + auto targetBottom = targetTop + itemHeight; + auto visibleTop = ImGui::GetScrollY(); + auto visibleBottom = visibleTop + windowHeight; + if (targetTop < visibleTop) + ImGui::SetScrollY(targetTop); + else if (targetBottom > visibleBottom) + ImGui::SetScrollY(targetBottom - windowHeight); + }; + int scrollTargetId = -1; + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) || ImGui::IsKeyPressed(ImGuiKey_DownArrow, true))) + { + auto& order = spritesheet->regionOrder; + if (!order.empty()) + { + int delta = ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) ? -1 : 1; + int current = reference; + if (current == -1 && !selection.empty()) current = *selection.begin(); + auto it = std::find(order.begin(), order.end(), current); + int index = it == order.end() ? 0 : (int)std::distance(order.begin(), it); + index = std::clamp(index + delta, 0, (int)order.size() - 1); + int nextId = order[index]; + selection = {nextId}; + reference = nextId; + document.reference = {document.reference.animationIndex}; + frame.reference = -1; + frame.selection.clear(); + scrollTargetId = nextId; + } + } bool isValid = spritesheet->is_valid(); auto& texture = isValid ? spritesheet->texture : resources.icons[icon::NONE]; auto tintColor = !isValid ? ImVec4(1.0f, 0.25f, 0.25f, 1.0f) : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); @@ -241,6 +276,8 @@ namespace anm2ed::imgui ImGui::PushID(id); + scroll_to_item(regionChildSize.y, scrollTargetId == id); + if (ImGui::BeginChild("##Region Child", regionChildSize, ImGuiChildFlags_Borders)) { auto cursorPos = ImGui::GetCursorPos(); @@ -254,6 +291,7 @@ namespace anm2ed::imgui frame.reference = -1; frame.selection.clear(); } + if (scrollTargetId == id) ImGui::SetItemDefaultFocus(); if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) propertiesPopup.open(); auto viewport = ImGui::GetMainViewport(); diff --git a/src/imgui/window/sounds.cpp b/src/imgui/window/sounds.cpp index 343a815..c0fd99d 100644 --- a/src/imgui/window/sounds.cpp +++ b/src/imgui/window/sounds.cpp @@ -1,6 +1,8 @@ #include "sounds.h" +#include #include +#include #include "log.h" #include "path_.h" @@ -220,12 +222,49 @@ namespace anm2ed::imgui selection.insert(id); } if (ImGui::Shortcut(ImGuiKey_Escape, ImGuiInputFlags_RouteFocused)) selection.clear(); + auto scroll_to_item = [&](float itemHeight, bool isTarget) + { + if (!isTarget) return; + auto windowHeight = ImGui::GetWindowHeight(); + auto targetTop = ImGui::GetCursorPosY(); + auto targetBottom = targetTop + itemHeight; + auto visibleTop = ImGui::GetScrollY(); + auto visibleBottom = visibleTop + windowHeight; + if (targetTop < visibleTop) + ImGui::SetScrollY(targetTop); + else if (targetBottom > visibleBottom) + ImGui::SetScrollY(targetBottom - windowHeight); + }; + int scrollTargetId = -1; + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) || ImGui::IsKeyPressed(ImGuiKey_DownArrow, true))) + { + std::vector ids{}; + ids.reserve(anm2.content.sounds.size()); + for (auto& [id, sound] : anm2.content.sounds) + ids.push_back(id); + if (!ids.empty()) + { + int delta = ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) ? -1 : 1; + int current = reference; + if (current == -1 && !selection.empty()) current = *selection.begin(); + auto it = std::find(ids.begin(), ids.end(), current); + int index = it == ids.end() ? 0 : (int)std::distance(ids.begin(), it); + index = std::clamp(index + delta, 0, (int)ids.size() - 1); + int nextId = ids[index]; + selection = {nextId}; + reference = nextId; + scrollTargetId = nextId; + } + } for (auto& [id, sound] : anm2.content.sounds) { auto isNewSound = newSoundId == id; ImGui::PushID(id); + scroll_to_item(soundChildSize.y, scrollTargetId == id); + if (ImGui::BeginChild("##Sound Child", soundChildSize, ImGuiChildFlags_Borders)) { auto isSelected = selection.contains(id); @@ -242,6 +281,7 @@ namespace anm2ed::imgui reference = id; if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) play(sound); } + if (scrollTargetId == id) ImGui::SetItemDefaultFocus(); if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) open_directory(sound); auto textWidth = ImGui::CalcTextSize(pathString.c_str()).x; diff --git a/src/imgui/window/spritesheet_editor.cpp b/src/imgui/window/spritesheet_editor.cpp index 01423da..31d45d2 100644 --- a/src/imgui/window/spritesheet_editor.cpp +++ b/src/imgui/window/spritesheet_editor.cpp @@ -655,7 +655,10 @@ namespace anm2ed::imgui if (isMouseClicked) document.snapshot(useTool == tool::DRAW ? localize.get(EDIT_DRAW) : localize.get(EDIT_ERASE)); if (isMouseDown) spritesheet->texture.pixel_line(ivec2(previousMousePos), ivec2(mousePos), color); - if (isMouseReleased) document.change(Document::SPRITESHEETS); + if (isMouseReleased) + { + document.change(Document::SPRITESHEETS); + } break; } case tool::COLOR_PICKER: diff --git a/src/imgui/window/spritesheets.cpp b/src/imgui/window/spritesheets.cpp index e1206d9..af9d1ab 100644 --- a/src/imgui/window/spritesheets.cpp +++ b/src/imgui/window/spritesheets.cpp @@ -1,6 +1,8 @@ #include "spritesheets.h" +#include #include +#include #include #include @@ -19,6 +21,8 @@ using namespace glm; namespace anm2ed::imgui { + static constexpr auto PADDING_MAX = 100; + void Spritesheets::update(Manager& manager, Settings& settings, Resources& resources, Dialog& dialog, Clipboard& clipboard) { @@ -44,7 +48,8 @@ namespace anm2ed::imgui auto id = *selection.begin(); if (!anm2.content.spritesheets.contains(id)) return; if (anm2.content.spritesheets.at(id).regions.empty()) return; - if (pack) pack(); + packId = id; + packPopup.open(); }; auto add = [&](const std::filesystem::path& path) @@ -85,6 +90,7 @@ namespace anm2ed::imgui { anm2::Spritesheet& spritesheet = anm2.content.spritesheets[id]; spritesheet.reload(document.directory_get()); + document.spritesheet_hash_set_saved(id); auto pathString = path::to_utf8(spritesheet.path); toasts.push(std::vformat(localize.get(TOAST_RELOAD_SPRITESHEET), std::make_format_args(id, pathString))); logger.info(std::vformat(localize.get(TOAST_RELOAD_SPRITESHEET, anm2ed::ENGLISH), @@ -104,6 +110,7 @@ namespace anm2ed::imgui auto& id = *selection.begin(); anm2::Spritesheet& spritesheet = anm2.content.spritesheets[id]; spritesheet.reload(document.directory_get(), path); + document.spritesheet_hash_set_saved(id); auto pathString = path::to_utf8(spritesheet.path); toasts.push(std::vformat(localize.get(TOAST_REPLACE_SPRITESHEET), std::make_format_args(id, pathString))); logger.info(std::vformat(localize.get(TOAST_REPLACE_SPRITESHEET, anm2ed::ENGLISH), @@ -113,16 +120,18 @@ namespace anm2ed::imgui DOCUMENT_EDIT(document, localize.get(EDIT_REPLACE_SPRITESHEET), Document::SPRITESHEETS, behavior()); }; - auto save = [&]() + auto save = [&](const std::set& ids) { - if (selection.empty()) return; + if (ids.empty()) return; - for (auto& id : selection) + for (auto& id : ids) { + if (!anm2.content.spritesheets.contains(id)) continue; anm2::Spritesheet& spritesheet = anm2.content.spritesheets[id]; auto pathString = path::to_utf8(spritesheet.path); if (spritesheet.save(document.directory_get())) { + document.spritesheet_hash_set_saved(id); toasts.push(std::vformat(localize.get(TOAST_SAVE_SPRITESHEET), std::make_format_args(id, pathString))); logger.info(std::vformat(localize.get(TOAST_SAVE_SPRITESHEET, anm2ed::ENGLISH), std::make_format_args(id, pathString))); @@ -136,6 +145,20 @@ namespace anm2ed::imgui } }; + auto save_open = [&]() + { + if (selection.empty()) return; + if (settings.fileIsWarnOverwrite) + { + saveSelection = selection; + overwritePopup.open(); + } + else + { + save(selection); + } + }; + auto merge = [&]() { if (mergeSelection.size() <= 1) return; @@ -165,12 +188,15 @@ namespace anm2ed::imgui }; pack = [&]() { - if (selection.size() != 1) return; + int id = packId != -1 ? packId : (selection.size() == 1 ? *selection.begin() : -1); + if (id == -1) return; + if (!anm2.content.spritesheets.contains(id)) return; + if (anm2.content.spritesheets.at(id).regions.empty()) return; auto behavior = [&]() { - auto id = *selection.begin(); - if (anm2.spritesheet_pack(id)) + auto padding = std::max(0, settings.packPadding); + if (anm2.spritesheet_pack(id, padding)) { toasts.push(localize.get(TOAST_PACK_SPRITESHEET)); logger.info(localize.get(TOAST_PACK_SPRITESHEET, anm2ed::ENGLISH)); @@ -213,7 +239,8 @@ namespace anm2ed::imgui auto behavior = [&]() { - auto maxSpritesheetIdBefore = anm2.content.spritesheets.empty() ? -1 : anm2.content.spritesheets.rbegin()->first; + auto maxSpritesheetIdBefore = + anm2.content.spritesheets.empty() ? -1 : anm2.content.spritesheets.rbegin()->first; std::string errorString{}; document.snapshot(localize.get(EDIT_PASTE_SPRITESHEETS)); if (anm2.spritesheets_deserialize(clipboard.get(), document.directory_get(), merge::APPEND, &errorString)) @@ -271,9 +298,8 @@ namespace anm2ed::imgui if (ImGui::MenuItem(localize.get(BASIC_ADD), settings.shortcutAdd.c_str())) add_open(); if (ImGui::MenuItem(localize.get(BASIC_REMOVE_UNUSED), settings.shortcutRemove.c_str())) remove_unused(); - bool isPackable = - selection.size() == 1 && anm2.content.spritesheets.contains(*selection.begin()) && - !anm2.content.spritesheets.at(*selection.begin()).regions.empty(); + bool isPackable = selection.size() == 1 && anm2.content.spritesheets.contains(*selection.begin()) && + !anm2.content.spritesheets.at(*selection.begin()).regions.empty(); if (ImGui::MenuItem(localize.get(BASIC_RELOAD), nullptr, false, !selection.empty())) reload(); if (ImGui::MenuItem(localize.get(BASIC_REPLACE), nullptr, false, selection.size() == 1)) replace_open(); @@ -282,7 +308,7 @@ namespace anm2ed::imgui if (ImGui::MenuItem(localize.get(BASIC_PACK), nullptr, false, isPackable)) pack_open(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetItemTooltip("%s", localize.get(TOOLTIP_PACK_SPRITESHEET)); - if (ImGui::MenuItem(localize.get(BASIC_SAVE), nullptr, false, !selection.empty())) save(); + if (ImGui::MenuItem(localize.get(BASIC_SAVE), nullptr, false, !selection.empty())) save_open(); ImGui::Separator(); @@ -313,12 +339,51 @@ namespace anm2ed::imgui selection.insert(id); } if (ImGui::Shortcut(ImGuiKey_Escape, ImGuiInputFlags_RouteFocused)) selection.clear(); + auto scroll_to_item = [&](float itemHeight, bool isTarget) + { + if (!isTarget) return; + auto windowHeight = ImGui::GetWindowHeight(); + auto targetTop = ImGui::GetCursorPosY(); + auto targetBottom = targetTop + itemHeight; + auto visibleTop = ImGui::GetScrollY(); + auto visibleBottom = visibleTop + windowHeight; + if (targetTop < visibleTop) + ImGui::SetScrollY(targetTop); + else if (targetBottom > visibleBottom) + ImGui::SetScrollY(targetBottom - windowHeight); + }; + int scrollTargetId = -1; + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) || ImGui::IsKeyPressed(ImGuiKey_DownArrow, true))) + { + std::vector ids{}; + ids.reserve(anm2.content.spritesheets.size()); + for (auto& [id, sheet] : anm2.content.spritesheets) + ids.push_back(id); + if (!ids.empty()) + { + int delta = ImGui::IsKeyPressed(ImGuiKey_UpArrow, true) ? -1 : 1; + int current = reference; + if (current == -1 && !selection.empty()) current = *selection.begin(); + auto it = std::find(ids.begin(), ids.end(), current); + int index = it == ids.end() ? 0 : (int)std::distance(ids.begin(), it); + index = std::clamp(index + delta, 0, (int)ids.size() - 1); + int nextId = ids[index]; + selection = {nextId}; + reference = nextId; + region.reference = -1; + region.selection.clear(); + scrollTargetId = nextId; + } + } for (auto& [id, spritesheet] : anm2.content.spritesheets) { auto isNewSpritesheet = newSpritesheetId == id; ImGui::PushID(id); + scroll_to_item(spritesheetChildSize.y, scrollTargetId == id); + if (ImGui::BeginChild("##Spritesheet Child", spritesheetChildSize, ImGuiChildFlags_Borders)) { auto isSelected = selection.contains(id); @@ -338,6 +403,7 @@ namespace anm2ed::imgui region.reference = -1; region.selection.clear(); } + if (scrollTargetId == id) ImGui::SetItemDefaultFocus(); if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) open_directory(spritesheet); @@ -411,8 +477,11 @@ namespace anm2ed::imgui spritesheetChildSize.y - spritesheetChildSize.y / 2 - ImGui::GetTextLineHeight() / 2)); if (isReferenced) ImGui::PushFont(resources.fonts[font::ITALICS].get(), font::SIZE); - ImGui::TextUnformatted( - std::vformat(localize.get(FORMAT_SPRITESHEET), std::make_format_args(id, pathCStr)).c_str()); + auto spritesheetLabel = std::vformat(localize.get(FORMAT_SPRITESHEET), std::make_format_args(id, pathCStr)); + if (document.spritesheet_is_dirty(id)) + spritesheetLabel = + std::vformat(localize.get(FORMAT_SPRITESHEET_NOT_SAVED), std::make_format_args(spritesheetLabel)); + ImGui::TextUnformatted(spritesheetLabel.c_str()); if (isReferenced) ImGui::PopFont(); } @@ -476,7 +545,7 @@ namespace anm2ed::imgui ImGui::SameLine(); ImGui::BeginDisabled(selection.empty()); - if (ImGui::Button(localize.get(BASIC_SAVE), rowTwoWidgetSize)) save(); + if (ImGui::Button(localize.get(BASIC_SAVE), rowTwoWidgetSize)) save_open(); ImGui::EndDisabled(); ImGui::SetItemTooltip("%s", localize.get(TOOLTIP_SAVE_SPRITESHEETS)); @@ -543,5 +612,68 @@ namespace anm2ed::imgui } mergePopup.end(); + packPopup.trigger(); + if (ImGui::BeginPopupModal(packPopup.label(), &packPopup.isOpen, ImGuiWindowFlags_NoResize)) + { + settings.packPadding = std::max(0, settings.packPadding); + + auto close = [&]() + { + packId = -1; + packPopup.close(); + }; + + auto optionsSize = child_size_get(1); + if (ImGui::BeginChild("##Pack Spritesheet Options", optionsSize, ImGuiChildFlags_Borders)) + { + ImGui::DragInt(localize.get(LABEL_PACK_PADDING), &settings.packPadding, DRAG_SPEED, 0, PADDING_MAX); + } + ImGui::EndChild(); + + auto widgetSize = widget_size_with_row_get(2); + shortcut(manager.chords[SHORTCUT_CONFIRM]); + bool isPackable = packId != -1 && anm2.content.spritesheets.contains(packId) && + !anm2.content.spritesheets.at(packId).regions.empty(); + ImGui::BeginDisabled(!isPackable); + if (ImGui::Button(localize.get(BASIC_PACK), widgetSize)) + { + if (pack) pack(); + close(); + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + shortcut(manager.chords[SHORTCUT_CANCEL]); + if (ImGui::Button(localize.get(BASIC_CANCEL), widgetSize)) close(); + + ImGui::EndPopup(); + } + packPopup.end(); + + overwritePopup.trigger(); + if (ImGui::BeginPopupModal(overwritePopup.label(), &overwritePopup.isOpen, ImGuiWindowFlags_NoResize)) + { + ImGui::TextUnformatted(localize.get(LABEL_OVERWRITE_CONFIRMATION)); + + auto widgetSize = widget_size_with_row_get(2); + + if (ImGui::Button(localize.get(BASIC_YES), widgetSize)) + { + save(saveSelection); + saveSelection.clear(); + overwritePopup.close(); + } + + ImGui::SameLine(); + + if (ImGui::Button(localize.get(BASIC_NO), widgetSize)) + { + saveSelection.clear(); + overwritePopup.close(); + } + + ImGui::EndPopup(); + } + overwritePopup.end(); } } diff --git a/src/imgui/window/spritesheets.h b/src/imgui/window/spritesheets.h index 1e3a9ad..595448a 100644 --- a/src/imgui/window/spritesheets.h +++ b/src/imgui/window/spritesheets.h @@ -12,7 +12,11 @@ namespace anm2ed::imgui { int newSpritesheetId{-1}; PopupHelper mergePopup{PopupHelper(LABEL_SPRITESHEETS_MERGE_POPUP, imgui::POPUP_SMALL_NO_HEIGHT)}; + PopupHelper packPopup{PopupHelper(LABEL_SPRITESHEETS_PACK_POPUP, imgui::POPUP_SMALL_NO_HEIGHT)}; + PopupHelper overwritePopup{PopupHelper(LABEL_TASKBAR_OVERWRITE_FILE, imgui::POPUP_SMALL_NO_HEIGHT)}; std::set mergeSelection{}; + int packId{-1}; + std::set saveSelection{}; public: void update(Manager&, Settings&, Resources&, Dialog&, Clipboard& clipboard); diff --git a/src/resource/strings.h b/src/resource/strings.h index 0532659..4a3dcac 100644 --- a/src/resource/strings.h +++ b/src/resource/strings.h @@ -200,6 +200,7 @@ namespace anm2ed X(FORMAT_SIZE, "Size: ({0}, {1})", "Tamaño: ({0}, {1})", "Размер: ({0}, {1})", "大小: ({0}, {1})", "크기: ({0}, {1})") \ X(FORMAT_SOUND_LABEL, "Sound: {0}", "Sonido: {0}", "Звук: {0}", "声音: {0}", "사운드: {0}") \ X(FORMAT_SPRITESHEET, "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}") \ + X(FORMAT_SPRITESHEET_NOT_SAVED, "{0} (Not Saved)", "{0} (No guardado)", "{0} (Не сохранено)", "{0} (未保存)", "{0} (저장되지 않음)") \ X(FORMAT_SOUND, "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}") \ X(FORMAT_REGION, "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}", "#{0} {1}") \ X(FORMAT_TRANSFORM, "Transform: {0}", "Transformar: {0}", "Трансформация: {0}", "变换: {0}", "변환: {0}") \ @@ -216,6 +217,7 @@ namespace anm2ed X(LABEL_ANIMATIONS_MERGE_POPUP, "Merge Animations", "Combinar Animaciones", "Соединить анимации", "合并多个动画", "애니메이션 병합") \ X(LABEL_SPRITESHEETS_PACK_POPUP, "Pack Spritesheet", "Empaquetar spritesheet", "Упаковать спрайт-лист", "打包图集", "스프라이트 시트 패킹") \ X(LABEL_SPRITESHEETS_MERGE_POPUP, "Merge Spritesheets", "Combinar Spritesheets", "Объединить спрайт-листы", "合并图集", "스프라이트 시트 병합") \ + X(LABEL_PACK_PADDING, "Padding", "Relleno", "Отступ", "填充", "패딩") \ X(LABEL_ANIMATIONS_WINDOW, "Animations###Animations", "Animaciones###Animations", "Анимации###Animations", "动画###Animations", "애니메이션###Animations") \ X(LABEL_REGIONS_WINDOW, "Regions###Regions", "Regiones###Regions", "Регионы###Regions", "区域###Regions", "영역###Regions") \ X(LABEL_ANIMATION_LENGTH, "Animation Length", "Duracion de Animacion", "Длина анимации", "动画时长", "애니메이션 길이") \ @@ -246,6 +248,8 @@ namespace anm2ed X(LABEL_DOCUMENTS_OPEN_NEW, "Open New Document", "Abrir Nuevo Documento", "Открыть новый документ", "打开新文件", "새 파일로 열기") \ X(LABEL_DOCUMENT_CLOSE, "Close Document", "Cerrar Documento", "Закрыть документ", "关闭文件", "파일 닫기") \ X(LABEL_DOCUMENT_MODIFIED_PROMPT, "The document \"{0}\" has been modified.\nDo you want to save it?", "El Documento \"{0}\" ha sido modificado.\n¿Quieres Guardarlo?", "Документ \"{0}\" был изменен. \nХотите сохранить его?", "此文件\"{0}\"已被更改.\n要保存吗?", "\"{0}\" 파일이 수정되었습니다.\n저장하시겠습니까?") \ + X(LABEL_DOCUMENT_AND_SPRITESHEETS_MODIFIED_PROMPT, "The document \"{0}\" and its spritesheets have been modified.\nDo you want to save them?", "El Documento \"{0}\" y sus spritesheets han sido modificados.\n¿Quieres guardarlos?", "Документ \"{0}\" и его спрайт-листы были изменены.\nХотите сохранить их?", "此文件\"{0}\"及其图集已被更改。\n要保存吗?", "\"{0}\" 파일과 스프라이트 시트가 수정되었습니다.\n저장하시겠습니까?") \ + X(LABEL_SPRITESHEETS_MODIFIED_PROMPT, "Spritesheets in \"{0}\" have been modified.\nDo you want to save them?", "Los spritesheets en \"{0}\" han sido modificados.\n¿Quieres guardarlos?", "Спрайт-листы в \"{0}\" были изменены.\nХотите сохранить их?", "\"{0}\" 中的图集已被修改。\n要保存吗?", "\"{0}\"의 스프라이트 시트가 수정되었습니다.\n저장하시겠습니까?") \ X(LABEL_END, "End", "Fin", "Конец", "结尾", "끝") \ X(LABEL_EVENT, "Event", "Evento", "Событие", "事件", "이벤트") \ X(LABEL_EVENTS_WINDOW, "Events###Events", "Eventos###Events", "События###Events", "事件###Events", "이벤트###Events") \ @@ -424,8 +428,7 @@ namespace anm2ed X(SNAPSHOT_RENAME_ANIMATION, "Rename Animation", "Renombrar Animacion", "Переименовать анимацию", "重命名动画", "애니메이션 이름 바꾸기") \ X(TEXT_SELECT_FRAME, "Select a frame first!", "¡Selecciona primero un frame!", "Сначала выберите кадр!", "请先选择帧!", "먼저 프레임을 선택하세요!") \ X(TEXT_SELECT_FRAME_OR_REGION, "Select a frame or region first!", "¡Selecciona primero un frame o región!", "Сначала выберите кадр или регион!", "请先选择帧或区域!", "먼저 프레임 또는 영역을 선택하세요!") \ - X(TEXT_MERGE_SPRITESHEETS_DESCRIPTION, "Merge selected spritesheets into the first selected spritesheet.", "Combina los spritesheets seleccionados en el primer spritesheet seleccionado.", "Объединить выбранные спрайт-листы в первый выбранный спрайт-лист.", "将所选图集合并到第一个选中的图集中。", "선택된 스프라이트 시트를 첫 번째 선택된 스프라이트 시트로 병합합니다.") \ - X(TEXT_PACK_SPRITESHEET_DESCRIPTION, "Pack this spritesheet using its region rectangles and rebuild the texture from packed regions.", "Empaqueta este spritesheet usando sus rectángulos de región y reconstruye la textura con las regiones empaquetadas.", "Упаковать этот спрайт-лист, используя прямоугольники его регионов, и пересобрать текстуру из упакованных регионов.", "使用该图集的区域矩形进行打包,并用打包后的区域重建纹理。", "이 스프라이트 시트의 영역 사각형을 기준으로 패킹하고, 패킹된 영역으로 텍스처를 다시 만듭니다.") \ + X(TOOLTIP_MERGE_SPRITESHEETS, "Merge selected spritesheets into the first selected spritesheet.", "Combina los spritesheets seleccionados en el primer spritesheet seleccionado.", "Объединить выбранные спрайт-листы в первый выбранный спрайт-лист.", "将所选图集合并到第一个选中的图集中。", "선택된 스프라이트 시트를 첫 번째 선택된 스프라이트 시트로 병합합니다.") \ X(TEXT_SELECT_SPRITESHEET, "Select a spritesheet first!", "¡Selecciona primero un spritesheet!", "Сначала выберите спрайт-лист!", "请先选择图集!", "먼저 스프라이트 시트를 선택하세요!") \ X(TEXT_TOOL_ANIMATION_PREVIEW, "This tool can only be used in Animation Preview!", "¡Esta herramienta solo se puede usar en Vista previa de animación!", "Этот инструмент можно использовать только в \"Предпросмотре анимации\"!", "该工具只能在“动画预放”中使用!", "이 도구는 애니메이션 프리뷰에서만 사용할 수 있습니다!") \ X(TEXT_TOOL_SPRITESHEET_EDITOR, "This tool can only be used in Spritesheet Editor!", "¡Esta herramienta solo se puede usar en el Editor de spritesheets!", "Этот инструмент можно использовать только в \"Редакторе спрайт-листов\"!", "该工具只能在“图集编辑器”中使用!", "이 도구는 스프라이트 시트 편집기에서만 사용할 수 있습니다!") \ diff --git a/src/settings.h b/src/settings.h index 4e8efdc..a14cc19 100644 --- a/src/settings.h +++ b/src/settings.h @@ -155,6 +155,7 @@ namespace anm2ed X(MERGE_SPRITESHEETS_ORIGIN, mergeSpritesheetsOrigin, STRING_UNDEFINED, INT, anm2::APPEND_RIGHT) \ X(MERGE_SPRITESHEETS_IS_MAKE_REGIONS, mergeSpritesheetsIsMakeRegions, STRING_UNDEFINED, BOOL, true) \ X(MERGE_SPRITESHEETS_REGION_ORIGIN, mergeSpritesheetsRegionOrigin, STRING_UNDEFINED, INT, origin::TOP_LEFT) \ + X(PACK_PADDING, packPadding, STRING_UNDEFINED, INT, 1) \ \ X(BAKE_INTERVAL, bakeInterval, STRING_UNDEFINED, INT, 1) \ X(BAKE_IS_ROUND_SCALE, bakeIsRoundScale, STRING_UNDEFINED, BOOL, true) \