diff --git a/data/gui.xml b/data/gui.xml index 90cd94b7f..8f1763df5 100644 --- a/data/gui.xml +++ b/data/gui.xml @@ -1203,6 +1203,7 @@ + diff --git a/data/strings/en.ini b/data/strings/en.ini index f8c0fc3a2..c39e2d345 100644 --- a/data/strings/en.ini +++ b/data/strings/en.ini @@ -265,6 +265,7 @@ Despeckle = Despeckle DeveloperConsole = Developer Console DiscardBrush = Discard Brush DuplicateLayer = Duplicate Layer +DuplicateSlice = Duplicate Slice DuplicateSprite = Duplicate Sprite DuplicateView = Duplicate View Exit = Exit @@ -1658,6 +1659,10 @@ from = From: to = To: tolerance = Tolerance: +[duplicate_slice] +x_duplicated = Slice "{}" duplicated +n_slices_duplicated = {} slice(s) duplicated + [remove_slice] x_removed = Slice "{}" removed n_slices_removed = {} slice(s) removed @@ -1736,6 +1741,7 @@ delete_file = Delete file, I've already sent it [slice_popup_menu] properties = Slice &Properties... +duplicate = D&uplicate Slice delete = &Delete Slice [slice_properties] diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index ae287e5ac..e0c92c7bf 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -394,6 +394,7 @@ target_sources(app-lib PRIVATE commands/cmd_deselect_mask.cpp commands/cmd_discard_brush.cpp commands/cmd_duplicate_layer.cpp + commands/cmd_duplicate_slice.cpp commands/cmd_duplicate_sprite.cpp commands/cmd_duplicate_view.cpp commands/cmd_enter_license.cpp @@ -713,6 +714,7 @@ target_sources(app-lib PRIVATE util/render_text.cpp util/resize_image.cpp util/shader_helpers.cpp + util/slice_utils.cpp util/tile_flags_utils.cpp util/tileset_utils.cpp util/wrap_point.cpp diff --git a/src/app/commands/cmd_duplicate_slice.cpp b/src/app/commands/cmd_duplicate_slice.cpp new file mode 100644 index 000000000..9e1ea21e8 --- /dev/null +++ b/src/app/commands/cmd_duplicate_slice.cpp @@ -0,0 +1,108 @@ +// Aseprite +// Copyright (C) 2025 Igara Studio S.A. +// +// This program is distributed under the terms of +// the End-User License Agreement for Aseprite. + +#ifdef HAVE_CONFIG_H + #include "config.h" +#endif + +#include "app/cmd/add_slice.h" +#include "app/commands/command.h" +#include "app/context.h" +#include "app/context_access.h" +#include "app/context_flags.h" +#include "app/i18n/strings.h" +#include "app/site.h" +#include "app/tx.h" +#include "app/ui/status_bar.h" +#include "app/util/slice_utils.h" +#include "base/convert_to.h" +#include "doc/object_id.h" +#include "doc/slice.h" + +namespace app { + +class DuplicateSliceCommand : public Command { +public: + DuplicateSliceCommand(); + +protected: + void onLoadParams(const Params& params) override; + bool onEnabled(Context* context) override; + void onExecute(Context* context) override; + +private: + ObjectId m_sliceId; +}; + +DuplicateSliceCommand::DuplicateSliceCommand() + : Command(CommandId::DuplicateSlice(), CmdRecordableFlag) +{ +} + +void DuplicateSliceCommand::onLoadParams(const Params& params) +{ + std::string id = params.get("id"); + if (!id.empty()) + m_sliceId = ObjectId(base::convert_to(id)); + else + m_sliceId = NullId; +} + +bool DuplicateSliceCommand::onEnabled(Context* context) +{ + return context->checkFlags(ContextFlags::ActiveDocumentIsWritable | + ContextFlags::HasActiveSprite | ContextFlags::HasActiveLayer); +} + +void DuplicateSliceCommand::onExecute(Context* context) +{ + std::vector selectedSlices; + { + const ContextReader reader(context); + if (m_sliceId == NullId) { + selectedSlices = get_selected_slices(reader.site()); + if (selectedSlices.empty()) + return; + } + else + selectedSlices.push_back(reader.sprite()->slices().getById(m_sliceId)); + } + + ContextWriter writer(context); + Tx tx(writer, "Duplicate Slice"); + Sprite* sprite = writer.site().sprite(); + + Doc* doc = static_cast(sprite->document()); + doc->notifyBeforeSlicesDuplication(); + for (auto* s : selectedSlices) { + Slice* slice = new Slice(*s); + slice->setName(get_unique_slice_name(sprite, s->name())); + tx(new cmd::AddSlice(sprite, slice)); + doc->notifySliceDuplicated(slice); + } + tx.commit(); + + std::string sliceName; + if (selectedSlices.size() == 1) + sliceName = selectedSlices[0]->name(); + + StatusBar::instance()->invalidate(); + if (!sliceName.empty()) { + StatusBar::instance()->showTip(1000, Strings::duplicate_slice_x_duplicated(sliceName)); + } + else { + StatusBar::instance()->showTip( + 1000, + Strings::duplicate_slice_n_slices_duplicated(selectedSlices.size())); + } +} + +Command* CommandFactory::createDuplicateSliceCommand() +{ + return new DuplicateSliceCommand; +} + +} // namespace app diff --git a/src/app/commands/commands_list.h b/src/app/commands/commands_list.h index 4177aec0b..7df6ff229 100644 --- a/src/app/commands/commands_list.h +++ b/src/app/commands/commands_list.h @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2023 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -40,6 +40,7 @@ FOR_EACH_COMMAND(DeselectMask) FOR_EACH_COMMAND(Despeckle) FOR_EACH_COMMAND(DiscardBrush) FOR_EACH_COMMAND(DuplicateLayer) +FOR_EACH_COMMAND(DuplicateSlice) FOR_EACH_COMMAND(DuplicateSprite) FOR_EACH_COMMAND(DuplicateView) FOR_EACH_COMMAND(Exit) diff --git a/src/app/doc.cpp b/src/app/doc.cpp index 21a8708b1..2523aa04c 100644 --- a/src/app/doc.cpp +++ b/src/app/doc.cpp @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2024 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -338,6 +338,19 @@ void Doc::notifyAfterAddTile(LayerTilemap* layer, frame_t frame, tile_index ti) notify_observers(&DocObserver::onAfterAddTile, ev); } +void Doc::notifyBeforeSlicesDuplication() +{ + DocEvent ev(this); + notify_observers(&DocObserver::onBeforeSlicesDuplication, ev); +} + +void Doc::notifySliceDuplicated(Slice* slice) +{ + DocEvent ev(this); + ev.slice(slice); + notify_observers(&DocObserver::onSliceDuplicated, ev); +} + bool Doc::isModified() const { return !m_undo->isInSavedStateOrSimilar(); diff --git a/src/app/doc.h b/src/app/doc.h index 6e692ac99..f6dd07245 100644 --- a/src/app/doc.h +++ b/src/app/doc.h @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2024 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -141,6 +141,8 @@ public: void notifyTilesetChanged(Tileset* tileset); void notifyLayerGroupCollapseChange(Layer* layer); void notifyAfterAddTile(LayerTilemap* layer, frame_t frame, tile_index ti); + void notifyBeforeSlicesDuplication(); + void notifySliceDuplicated(Slice* slice); ////////////////////////////////////////////////////////////////////// // File related properties diff --git a/src/app/doc_observer.h b/src/app/doc_observer.h index 033c91516..208a5b0a7 100644 --- a/src/app/doc_observer.h +++ b/src/app/doc_observer.h @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2024 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -90,6 +90,8 @@ public: // Slices virtual void onSliceNameChange(DocEvent& ev) {} + virtual void onBeforeSlicesDuplication(DocEvent& ev) {} + virtual void onSliceDuplicated(DocEvent& ev) {} // The tileset has changed. virtual void onTilesetChanged(DocEvent& ev) {} diff --git a/src/app/ui/doc_view.cpp b/src/app/ui/doc_view.cpp index 18aebbda0..43fe8a763 100644 --- a/src/app/ui/doc_view.cpp +++ b/src/app/ui/doc_view.cpp @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2024 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -36,9 +36,11 @@ #include "app/ui/workspace.h" #include "app/ui_context.h" #include "app/util/clipboard.h" +#include "app/util/slice_utils.h" #include "base/fs.h" #include "doc/color.h" #include "doc/layer.h" +#include "doc/slice.h" #include "doc/sprite.h" #include "fmt/format.h" #include "ui/accelerator.h" @@ -510,6 +512,8 @@ bool DocView::onCanCopy(Context* ctx) return true; else if (m_editor->isMovingPixels()) return true; + else if (m_editor->hasSelectedSlices()) + return true; else return false; } @@ -528,6 +532,11 @@ bool DocView::onCanPaste(Context* ctx) return true; } } + + if (ctx->checkFlags(ContextFlags::ActiveDocumentIsWritable) && + ctx->clipboard()->format() == ClipboardFormat::Slices) { + return true; + } return false; } @@ -560,15 +569,22 @@ bool DocView::onCopy(Context* ctx) ctx->clipboard()->copy(reader); return true; } - else - return false; + + std::vector selectedSlices = get_selected_slices(reader.site()); + if (!selectedSlices.empty()) { + ctx->clipboard()->copySlices(selectedSlices); + return true; + } + + return false; } bool DocView::onPaste(Context* ctx, const gfx::Point* position) { auto clipboard = ctx->clipboard(); if (clipboard->format() == ClipboardFormat::Image || - clipboard->format() == ClipboardFormat::Tilemap) { + clipboard->format() == ClipboardFormat::Tilemap || + clipboard->format() == ClipboardFormat::Slices) { clipboard->paste(ctx, true, position); return true; } diff --git a/src/app/ui/editor/editor.cpp b/src/app/ui/editor/editor.cpp index 069cc2173..16de20a58 100644 --- a/src/app/ui/editor/editor.cpp +++ b/src/app/ui/editor/editor.cpp @@ -2534,6 +2534,16 @@ void Editor::onBeforeLayerEditableChange(DocEvent& ev, bool newState) m_state->onBeforeLayerEditableChange(this, ev.layer(), newState); } +void Editor::onBeforeSlicesDuplication(DocEvent& ev) +{ + clearSlicesSelection(); +} + +void Editor::onSliceDuplicated(DocEvent& ev) +{ + selectSlice(ev.slice()); +} + void Editor::setCursor(const gfx::Point& mouseDisplayPos) { Rect vp = View::getView(this)->viewportBounds(); diff --git a/src/app/ui/editor/editor.h b/src/app/ui/editor/editor.h index 038fa5361..4c378d97a 100644 --- a/src/app/ui/editor/editor.h +++ b/src/app/ui/editor/editor.h @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2018-2024 Igara Studio S.A. +// Copyright (C) 2018-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -343,6 +343,8 @@ protected: void onRemoveSlice(DocEvent& ev) override; void onBeforeLayerVisibilityChange(DocEvent& ev, bool newState) override; void onBeforeLayerEditableChange(DocEvent& ev, bool newState) override; + void onBeforeSlicesDuplication(DocEvent& ev) override; + void onSliceDuplicated(DocEvent& ev) override; // ActiveToolObserver impl void onActiveToolChange(tools::Tool* tool) override; diff --git a/src/app/ui/editor/tool_loop_impl.cpp b/src/app/ui/editor/tool_loop_impl.cpp index 71d9150e4..c867278a8 100644 --- a/src/app/ui/editor/tool_loop_impl.cpp +++ b/src/app/ui/editor/tool_loop_impl.cpp @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2019-2024 Igara Studio S.A. +// Copyright (C) 2019-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -45,6 +45,7 @@ #include "app/ui_context.h" #include "app/util/expand_cel_canvas.h" #include "app/util/layer_utils.h" +#include "app/util/slice_utils.h" #include "doc/brush.h" #include "doc/cel.h" #include "doc/image.h" @@ -693,7 +694,7 @@ public: // popup menu to create a new one. if (!m_editor->selectSliceBox(bounds) && (bounds.w > 1 || bounds.h > 1)) { Slice* slice = new Slice; - slice->setName(getUniqueSliceName()); + slice->setName(get_unique_slice_name(m_sprite)); SliceKey key(bounds); slice->insert(getFrame(), key); @@ -716,18 +717,6 @@ private: // EditorObserver impl void onScrollChanged(Editor* editor) override { updateAllVisibleRegion(); } void onZoomChanged(Editor* editor) override { updateAllVisibleRegion(); } - - std::string getUniqueSliceName() const - { - std::string prefix = "Slice"; - int max = 0; - - for (Slice* slice : m_sprite->slices()) - if (std::strncmp(slice->name().c_str(), prefix.c_str(), prefix.size()) == 0) - max = std::max(max, (int)std::strtol(slice->name().c_str() + prefix.size(), nullptr, 10)); - - return fmt::format("{} {}", prefix, max + 1); - } }; ////////////////////////////////////////////////////////////////////// diff --git a/src/app/util/clipboard.cpp b/src/app/util/clipboard.cpp index 92026df1d..c8f5fb6af 100644 --- a/src/app/util/clipboard.cpp +++ b/src/app/util/clipboard.cpp @@ -10,6 +10,7 @@ #endif #include "app/app.h" +#include "app/cmd/add_slice.h" #include "app/cmd/clear_mask.h" #include "app/cmd/deselect_mask.h" #include "app/cmd/set_mask.h" @@ -32,6 +33,7 @@ #include "app/util/cel_ops.h" #include "app/util/clipboard.h" #include "app/util/new_image_from_mask.h" +#include "app/util/slice_utils.h" #include "clip/clip.h" #include "doc/algorithm/shrink_bounds.h" #include "doc/blend_image.h" @@ -114,6 +116,9 @@ struct Clipboard::Data { // Selected set of layers/layers/cels ClipboardRange range; + // Selected slices + std::vector slices; + Data() { range.observeUIContext(); } ~Data() @@ -132,6 +137,7 @@ struct Clipboard::Data { picks.clear(); mask.reset(); range.invalidate(); + slices.clear(); } ClipboardFormat format() const @@ -146,6 +152,8 @@ struct Clipboard::Data { return ClipboardFormat::PaletteEntries; else if (tileset && picks.picks()) return ClipboardFormat::Tileset; + else if (!slices.empty()) + return ClipboardFormat::Slices; else return ClipboardFormat::None; } @@ -212,6 +220,7 @@ void Clipboard::setData(Image* image, Mask* mask, Palette* palette, Tileset* tileset, + const std::vector* slices, bool set_native_clipboard, bool image_source_is_transparent) { @@ -226,6 +235,11 @@ void Clipboard::setData(Image* image, else m_data->image.reset(image); + if (slices) { + for (auto* slice : *slices) + m_data->slices.push_back(*slice); + } + if (set_native_clipboard && use_native_clipboard()) { // Copy tilemap to the native clipboard if (isTilemap) { @@ -262,6 +276,7 @@ bool Clipboard::copyFromDocument(const Site& site, bool merged) (mask ? new Mask(*mask) : nullptr), (pal ? new Palette(*pal) : nullptr), Tileset::MakeCopyCopyingImages(ts), + nullptr, true, // set native clipboard site.layer() && !site.layer()->isBackground()); @@ -277,6 +292,7 @@ bool Clipboard::copyFromDocument(const Site& site, bool merged) (mask ? new Mask(*mask) : nullptr), (pal ? new Palette(*pal) : nullptr), nullptr, + nullptr, true, // set native clipboard site.layer() && !site.layer()->isBackground()); @@ -401,6 +417,7 @@ void Clipboard::copyImage(const Image* image, const Mask* mask, const Palette* p (mask ? new Mask(*mask) : nullptr), (pal ? new Palette(*pal) : nullptr), nullptr, + nullptr, App::instance()->isGui(), false); } @@ -415,6 +432,7 @@ void Clipboard::copyTilemap(const Image* image, (mask ? new Mask(*mask) : nullptr), (pal ? new Palette(*pal) : nullptr), Tileset::MakeCopyCopyingImages(tileset), + nullptr, true, false); } @@ -428,6 +446,7 @@ void Clipboard::copyPalette(const Palette* palette, const PalettePicks& picks) nullptr, new Palette(*palette), nullptr, + nullptr, false, // Don't touch the native clipboard now false); @@ -438,6 +457,20 @@ void Clipboard::copyPalette(const Palette* palette, const PalettePicks& picks) m_data->picks = picks; } +void Clipboard::copySlices(const std::vector slices) +{ + if (slices.empty()) + return; + + setData(nullptr, + nullptr, + nullptr, + nullptr, + &slices, + false, // Don't touch the native clipboard now + false); +} + void Clipboard::paste(Context* ctx, const bool interactive, const gfx::Point* position) { const Site site = ctx->activeSite(); @@ -782,6 +815,26 @@ void Clipboard::paste(Context* ctx, const bool interactive, const gfx::Point* po } break; } + + case ClipboardFormat::Slices: { + auto& slices = m_data->slices; + + if (slices.empty()) + return; + + ContextWriter writer(ctx); + Tx tx(writer, "Paste Slices"); + editor->clearSlicesSelection(); + for (auto& s : slices) { + Slice* slice = new Slice(s); + slice->setName(get_unique_slice_name(dstSpr, s.name())); + tx(new cmd::AddSlice(dstSpr, slice)); + editor->selectSlice(slice); + } + tx.commit(); + updateDstDoc = true; + break; + } } // Update all editors/views showing this document @@ -799,7 +852,7 @@ ImageRef Clipboard::getImage(Palette* palette) Tileset* native_tileset = nullptr; getNativeBitmap(&native_image, &native_mask, &native_palette, &native_tileset); if (native_image) { - setData(native_image, native_mask, native_palette, native_tileset, false, false); + setData(native_image, native_mask, native_palette, native_tileset, nullptr, false, false); } } if (m_data->palette && palette) diff --git a/src/app/util/clipboard.h b/src/app/util/clipboard.h index 5b36b1b3a..bf77d2f4a 100644 --- a/src/app/util/clipboard.h +++ b/src/app/util/clipboard.h @@ -1,5 +1,5 @@ // Aseprite -// Copyright (C) 2019-2024 Igara Studio S.A. +// Copyright (C) 2019-2025 Igara Studio S.A. // Copyright (C) 2001-2018 David Capello // // This program is distributed under the terms of @@ -23,6 +23,7 @@ class Image; class Mask; class Palette; class PalettePicks; +class Slice; class Tileset; } // namespace doc @@ -45,6 +46,7 @@ enum class ClipboardFormat { PaletteEntries, Tilemap, Tileset, + Slices, }; class Clipboard : public ui::ClipboardDelegate { @@ -74,6 +76,7 @@ public: const doc::Palette* pal, const doc::Tileset* tileset); void copyPalette(const doc::Palette* palette, const doc::PalettePicks& picks); + void copySlices(const std::vector slices); void paste(Context* ctx, const bool interactive, const gfx::Point* position = nullptr); doc::ImageRef getImage(doc::Palette* palette); @@ -106,6 +109,7 @@ private: doc::Mask* mask, doc::Palette* palette, doc::Tileset* tileset, + const std::vector* slices, bool set_native_clipboard, bool image_source_is_transparent); bool copyFromDocument(const Site& site, bool merged = false); diff --git a/src/app/util/slice_utils.cpp b/src/app/util/slice_utils.cpp new file mode 100644 index 000000000..cdc34c6c5 --- /dev/null +++ b/src/app/util/slice_utils.cpp @@ -0,0 +1,42 @@ +// Aseprite +// Copyright (C) 2025 Igara Studio S.A. +// +// This program is distributed under the terms of +// the End-User License Agreement for Aseprite. + +#include "app/util/slice_utils.h" + +#include "app/context_access.h" +#include "app/site.h" +#include "doc/slice.h" +#include "doc/sprite.h" +#include "fmt/format.h" + +namespace app { + +std::string get_unique_slice_name(const doc::Sprite* sprite, const std::string& namePrefix) +{ + std::string prefix = namePrefix.empty() ? "Slice" : namePrefix; + int max = 0; + + for (doc::Slice* slice : sprite->slices()) + if (std::strncmp(slice->name().c_str(), prefix.c_str(), prefix.size()) == 0) + max = std::max(max, (int)std::strtol(slice->name().c_str() + prefix.size(), nullptr, 10)); + + return fmt::format("{} {}", prefix, max + 1); +} + +std::vector get_selected_slices(const Site& site) +{ + std::vector selectedSlices; + if (site.sprite() && !site.selectedSlices().empty()) { + for (auto* slice : site.sprite()->slices()) { + if (site.selectedSlices().contains(slice->id())) { + selectedSlices.push_back(slice); + } + } + } + return selectedSlices; +} + +} // namespace app diff --git a/src/app/util/slice_utils.h b/src/app/util/slice_utils.h new file mode 100644 index 000000000..a7d778fa8 --- /dev/null +++ b/src/app/util/slice_utils.h @@ -0,0 +1,30 @@ +// Aseprite +// Copyright (C) 2025 Igara Studio S.A. +// +// This program is distributed under the terms of +// the End-User License Agreement for Aseprite. + +#ifndef APP_SLICE_UTILS_H_INCLUDED +#define APP_SLICE_UTILS_H_INCLUDED +#pragma once + +#include +#include + +namespace doc { +class Slice; +class Sprite; +} // namespace doc + +namespace app { + +class Site; + +std::string get_unique_slice_name(const doc::Sprite* sprite, + const std::string& namePrefix = std::string()); + +std::vector get_selected_slices(const Site& site); + +} // namespace app + +#endif diff --git a/src/doc/slices.cpp b/src/doc/slices.cpp index 09572d0c2..71b349a9e 100644 --- a/src/doc/slices.cpp +++ b/src/doc/slices.cpp @@ -31,7 +31,10 @@ Slices::~Slices() void Slices::add(Slice* slice) { - m_slices.push_back(slice); + // Insert the slice at the begining to display it at the front of the others. + // This is useful when duplicating (or copy & pasting) slices, because the + // user can drag the new slices instead of the originally selected ones. + m_slices.insert(m_slices.begin(), slice); slice->setOwner(this); } diff --git a/tests/scripts/slices.lua b/tests/scripts/slices.lua index 42e3d2589..7109514a7 100644 --- a/tests/scripts/slices.lua +++ b/tests/scripts/slices.lua @@ -14,7 +14,7 @@ do assert(b.bounds == Rectangle(0, 2, 8, 10)) assert(c.bounds == Rectangle(0, 0, 32, 32)) - local bounds = { nil, Rectangle(0, 2, 8, 10), Rectangle(0, 0, 32, 32) } + local bounds = { Rectangle(0, 0, 32, 32), Rectangle(0, 2, 8, 10), nil } local i = 1 for k,v in ipairs(s.slices) do @@ -25,8 +25,8 @@ do end s:deleteSlice(b) - assert(a == s.slices[1]) - assert(c == s.slices[2]) + assert(c == s.slices[1]) + assert(a == s.slices[2]) assert(2 == #s.slices) app.undo()