From 6ea248367dfbb628b891741a24c5510e6db2c029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 16:22:27 -0400 Subject: [PATCH] =?UTF-8?q?Layer=20=E2=80=BA=20Ungroup=20Layers=20(Ctrl+Sh?= =?UTF-8?q?ift+G),=20in=20a=20folder's=20menu=20too;=20Alt=20over=20a=20ma?= =?UTF-8?q?sk=20shows=20the=20eye=20pointer=20(13.15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Document/LayerGroups+Session.h | 3 + src/Document/LayerGroups.cpp | 38 ++++++++ src/UI/CompositorMenus.cpp | 2 + src/UI/KeyboardShortcuts.cpp | 2 +- src/UI/NativeLayerList+Drag.cpp | 36 +++++++- src/UI/NativeLayerList+Menu.cpp | 4 + src/UI/NativeLayerList.h | 2 + tests/CompositorMenusTests.cpp | 15 +++- tests/GroupSessionTests.cpp | 135 +++++++++++++++++++++++++++++ tests/KeyboardShortcutsTests.cpp | 4 +- tests/LayerContextMenuTests.cpp | 26 ++++++ tests/LayerDragGestureTests.cpp | 16 +++- 12 files changed, 271 insertions(+), 12 deletions(-) diff --git a/src/Document/LayerGroups+Session.h b/src/Document/LayerGroups+Session.h index d8847fe..5c7c821 100644 --- a/src/Document/LayerGroups+Session.h +++ b/src/Document/LayerGroups+Session.h @@ -7,6 +7,9 @@ public: void selectLayers(const QSet &ids, std::optional primary); void extendSelection(QUuid id); void groupSelectedLayers(); + // The active layer is a folder: something to unwrap. + bool canUngroupLayers() const; + void ungroupLayers(); std::vector layerRows() const; bool canPlaceLayer(QUuid id, std::optional parent) const; bool placeLayer(QUuid id, std::optional parent, std::optional above = std::nullopt, bool atBottom = false); diff --git a/src/Document/LayerGroups.cpp b/src/Document/LayerGroups.cpp index a3726bf..bba445a 100644 --- a/src/Document/LayerGroups.cpp +++ b/src/Document/LayerGroups.cpp @@ -317,6 +317,44 @@ void EditorSession::groupSelectedLayers() endEdit(); } +bool EditorSession::canUngroupLayers() const +{ + const std::optional active = activeLayer(); + return canEditLayers() && active && active->isGroup; +} + +// The folder's children take its place, in order. +void EditorSession::ungroupLayers() +{ + if (!canUngroupLayers()) + return; + const ImageLayer group = activeLayer().value(); + std::vector children; + for (const ImageLayer &layer : m_document->layers) { + if (layer.parentID == group.id) { + children.push_back(layer); + children.back().parentID = group.parentID; + } + } + std::vector layers; + QSet childIDs; + for (const ImageLayer &child : children) + childIDs.insert(child.id); + for (const ImageLayer &layer : m_document->layers) { + if (layer.id == group.id) + layers.insert(layers.end(), children.begin(), children.end()); + else if (!childIDs.contains(layer.id)) + layers.push_back(layer); + } + releaseDetachedClipping(layers); + finishOpacityEdit(); + beginEdit(QStringLiteral("Ungroup Layers")); + m_document->layers = layers; + selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id)); + m_collapsedGroupIDs.remove(group.id); + endEdit(); +} + std::vector EditorSession::layerRows() const { std::vector records; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 26f002d..2800289 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -255,6 +255,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi }); layer->addSeparator(); add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); }); + add(layer, QStringLiteral("ungroupLayers"), QStringLiteral("Ungroup Layers"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_G), [this] { session().ungroupLayers(); }); add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); }); add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); }); add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); }); @@ -407,6 +408,7 @@ void CompositorMenus::synchronize() action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask")); action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID())); action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("ungroupLayers"))->setEnabled(s.canUngroupLayers()); action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID); action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers()); action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active); diff --git a/src/UI/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp index ffa2db5..12121e4 100644 --- a/src/UI/KeyboardShortcuts.cpp +++ b/src/UI/KeyboardShortcuts.cpp @@ -134,7 +134,7 @@ const std::vector &ShortcutDefinition::all() entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true), entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true), entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true), - entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true), + entry("Group Layers", "g", 1, true), entry("Ungroup Layers", "g", 9, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true), entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true), entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)}; const std::vector> tools{ diff --git a/src/UI/NativeLayerList+Drag.cpp b/src/UI/NativeLayerList+Drag.cpp index c8a3005..b277ee8 100644 --- a/src/UI/NativeLayerList+Drag.cpp +++ b/src/UI/NativeLayerList+Drag.cpp @@ -13,6 +13,7 @@ #include #include #include +#include const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask"); const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list"); @@ -299,6 +300,37 @@ QCursor NativeLayerList::clippingCursor(bool releasing, double ratio) return QCursor(pixmap, 3, 3); } +// Swift's eye.fill: an almond, a ring round the pupil. +QCursor NativeLayerList::showMaskCursor(double ratio) +{ + const QCursor base = CanvasView::duplicateCursor(ratio); + const QPointF hotSpot = base.hotSpot(); + const QRectF eye(hotSpot + QPointF(15, 18), QSizeF(7.5, 5.5)); + const QSizeF baseSize = base.pixmap().deviceIndependentSize(); + const QSize size(int(std::ceil(std::max(baseSize.width(), eye.right() + 2))), int(std::ceil(std::max(baseSize.height(), eye.bottom() + 2)))); + QPixmap pixmap(size * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath shape; + shape.setFillRule(Qt::OddEvenFill); + shape.moveTo(eye.left(), eye.center().y()); + shape.quadTo(eye.center().x(), eye.top() - eye.height() / 2, eye.right(), eye.center().y()); + shape.quadTo(eye.center().x(), eye.bottom() + eye.height() / 2, eye.left(), eye.center().y()); + shape.addEllipse(eye.center(), 1.9, 1.9); + shape.addEllipse(eye.center(), 1.1, 1.1); + // The eye first, haloed, so the arrows sit in front. + for (int step = 0; step < 16; ++step) { + const double angle = step * M_PI / 8; + painter.fillPath(shape.translated(std::cos(angle), std::sin(angle)), Qt::white); + } + painter.fillPath(shape, Qt::black); + painter.drawPixmap(QPointF(0, 0), base.pixmap()); + painter.end(); + return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y())); +} + QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const { const int row = rowAt(listPoint); @@ -317,9 +349,9 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif return QCursor(Qt::ArrowCursor); } const bool editable = m_session.canEditLayers(); - // A mask copies, the row duplicates, the strip clips. + // A mask shows alone, the row duplicates, the strip clips. if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell)) - return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); + return showMaskCursor(ratio); if (!isClippingZone(cell, inCell)) return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); if (!m_session.canToggleClippingMask(layer.id)) diff --git a/src/UI/NativeLayerList+Menu.cpp b/src/UI/NativeLayerList+Menu.cpp index 6c7bdd0..7aa645c 100644 --- a/src/UI/NativeLayerList+Menu.cpp +++ b/src/UI/NativeLayerList+Menu.cpp @@ -1,5 +1,6 @@ #include "UI/NativeLayerList.h" #include +#include // Swift's table-level context menu (1.2.5): entries and validation. std::unique_ptr NativeLayerList::contextMenu(QUuid id) @@ -40,6 +41,9 @@ std::unique_ptr NativeLayerList::contextMenu(QUuid id) entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"), session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(), [&session] { session.groupSelectedLayers(); }); + // A folder right-clicked can be ungrouped. + if (std::ranges::any_of(session.document()->layers, [id](const ImageLayer &layer) { return layer.id == id && layer.isGroup; })) + entry(*menu, "ungroupLayers", QStringLiteral("Ungroup Layers"), session.canUngroupLayers(), [&session] { session.ungroupLayers(); }); entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID, [&session] { session.moveActiveLayerOutOfGroup(); }); entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); }); diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index 33028f8..2212cbe 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -242,6 +242,8 @@ public: // The cursor Alt asks for here: clip, duplicate, arrow. QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const; static QCursor clippingCursor(bool releasing, double ratio); + // Alt over a mask: the duplicate pointer, an eye behind. + static QCursor showMaskCursor(double ratio); protected: bool event(QEvent *event) override; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 9e473de..8f92934 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -32,7 +32,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() {"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"}, {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"gridSettings", ""}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"}, - {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, + {"ungroupLayers", "Ctrl+Shift+G"}, {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, {"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""}, {"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"colorRange", ""}, {"maskBlackAreas", ""}, {"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"}, @@ -72,7 +72,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() for (QAction *entry : menus[5]->actions()) filters << entry->text(); QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); - QCOMPARE(menus[6]->actions().size(), 22); + QCOMPARE(menus[6]->actions().size(), 23); } void CompositorMenusTests::fileEntriesFollowTheControllersGate() @@ -255,7 +255,7 @@ void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo() void CompositorMenusTests::layerEntriesFollowTheActiveLayer() { Bar bar; - for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) QVERIFY2(!bar.action(name).isEnabled(), name); bar.session().createDocument(8, 8); QVERIFY(bar.action("groupLayers").isEnabled() && bar.action("newBlankLayer").isEnabled()); @@ -297,9 +297,16 @@ void CompositorMenusTests::layerEntriesFollowTheActiveLayer() bar.action("moveOutOfFolder").trigger(); QCOMPARE(bar.session().activeLayer().value().parentID, std::nullopt); QVERIFY(!bar.action("moveOutOfFolder").isEnabled()); + QVERIFY(!bar.action("ungroupLayers").isEnabled()); + bar.session().selectLayers({lower}, lower); + const std::optional outer = bar.session().activeLayer().value().parentID; + bar.action("groupLayers").trigger(); + QVERIFY(bar.action("ungroupLayers").isEnabled() && bar.action("ungroupLayers").text() == "Ungroup Layers"); + bar.action("ungroupLayers").trigger(); + QVERIFY(outer && bar.session().activeLayer().value().parentID == outer && bar.session().history.undoName() == "Ungroup Layers"); // A busy project closes every layer entry. bar.session().setIsProjectBusy(true); - for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) QVERIFY2(!bar.action(name).isEnabled(), name); } diff --git a/tests/GroupSessionTests.cpp b/tests/GroupSessionTests.cpp index 91455ea..5009fc0 100644 --- a/tests/GroupSessionTests.cpp +++ b/tests/GroupSessionTests.cpp @@ -3,6 +3,7 @@ #include "IO/ImageExporter.h" #include "IO/ImageResizer.h" #include +#include #include // The session cases of Swift's GroupTests. @@ -42,6 +43,10 @@ private slots: void hiddenParentOverridesChildrenAndExportOrderFollowsGroups(); void groupsRoundTripAndSurviveImageAndCanvasResize(); void rowsOfASessionWithoutADocument(); + void ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes(); + void ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren(); + void ungroupingReleasesClippingThatNoLongerMakesSense(); + void ungroupingANestedOrEmptyFolder(); }; void GroupSessionTests::nestedGroupsMoveOutCollapseAndDeleteUndo() @@ -249,5 +254,135 @@ void GroupSessionTests::rowsOfASessionWithoutADocument() QVERIFY(EditorSession().layerRows().empty()); } +void GroupSessionTests::ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid below = session.activeLayerID().value(); + QVERIFY(!session.canUngroupLayers()); + session.addGroup(); + const QUuid group = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid childA = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid childB = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid above = session.activeLayerID().value(); + QCOMPARE(parentOf(session, childA), std::optional(group)); + session.selectLayer(group); + session.toggleGroupExpansion(group); + QVERIFY(session.collapsedGroupIDs().contains(group)); + QVERIFY(session.canUngroupLayers()); + const int undoCount = session.history.undoCount(); + const qsizetype count = qsizetype(session.document().value().layers.size()); + QSignalSpy changed(&session, &EditorSession::changed); + session.ungroupLayers(); + QVERIFY(!changed.isEmpty()); + std::vector order; + for (const ImageLayer &layer : session.document().value().layers) + order.push_back(layer.id); + QVERIFY(std::ranges::find(order, group) == order.end()); + QCOMPARE(parentOf(session, childA), std::nullopt); + QCOMPARE(parentOf(session, childB), std::nullopt); + // Spliced in where the folder sat. + const auto at = [&](QUuid id) { return std::ranges::find(order, id) - order.begin(); }; + QCOMPARE(at(childA), at(below) + 1); + QCOMPARE(at(childB), at(childA) + 1); + QCOMPARE(at(above), at(childB) + 1); + QCOMPARE(qsizetype(order.size()), count - 1); + QCOMPARE(session.selectedLayerIDs(), (QSet{childA, childB})); + QCOMPARE(session.activeLayerID(), std::optional(childA)); + QVERIFY(!session.collapsedGroupIDs().contains(group)); + QCOMPARE(session.history.undoCount(), undoCount + 1); + QCOMPARE(session.history.undoName(), QString("Ungroup Layers")); + session.undo(); + QCOMPARE(parentOf(session, childA), std::optional(group)); + session.redo(); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); + // A plain layer has nothing to unwrap; nothing changes. + changed.clear(); + session.selectLayer(childA); + changed.clear(); + session.ungroupLayers(); + QVERIFY(changed.isEmpty()); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); +} + +void GroupSessionTests::ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid base = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid clipped = session.activeLayerID().value(); + QVERIFY(session.linkMask(base, clipped)); + session.selectLayers({base, clipped}, base); + session.groupSelectedLayers(); + session.ungroupLayers(); + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == clipped) + QCOMPARE(layer.maskSourceID, std::optional(base)); + } +} + +void GroupSessionTests::ungroupingReleasesClippingThatNoLongerMakesSense() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid outsideBase = session.activeLayerID().value(); + session.addBlankLayer(); + session.addBlankLayer(); + const QUuid childSource = session.activeLayerID().value(); + // linkMask allows a clip across a folder's edge. + QVERIFY(session.linkMask(outsideBase, childSource)); + session.selectLayer(childSource); + session.groupSelectedLayers(); + session.ungroupLayers(); + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == childSource) + QCOMPARE(layer.maskSourceID, std::nullopt); + } +} + +void GroupSessionTests::ungroupingANestedOrEmptyFolder() +{ + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid outer = session.activeLayerID().value(); + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + // Children join the folder's parent; a drag ends first. + session.selectLayer(inner); + session.beginOpacityEdit(); + session.setLayerOpacity(0.5); + QVERIFY(!session.canUndo()); + session.ungroupLayers(); + QVERIFY(session.canUndo()); + QCOMPARE(session.history.undoName(), QString("Ungroup Layers")); + QCOMPARE(parentOf(session, child), std::optional(outer)); + QCOMPARE(session.selectedLayerIDs(), QSet{child}); + // An empty folder just goes, nothing left chosen. + session.addGroup(); + const QUuid empty = session.activeLayerID().value(); + const qsizetype count = qsizetype(session.document().value().layers.size()); + session.ungroupLayers(); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); + QVERIFY(std::ranges::none_of(session.document().value().layers, [&](const ImageLayer &layer) { return layer.id == empty; })); + QVERIFY(session.selectedLayerIDs().isEmpty() && !session.activeLayerID()); + // Busy, it rests. + session.selectLayer(outer); + session.setIsProjectBusy(true); + QVERIFY(!session.canUngroupLayers()); + session.setIsProjectBusy(false); + QVERIFY(session.canUngroupLayers()); +} + QTEST_GUILESS_MAIN(GroupSessionTests) #include "GroupSessionTests.moc" diff --git a/tests/KeyboardShortcutsTests.cpp b/tests/KeyboardShortcutsTests.cpp index bd2370d..bdc495a 100644 --- a/tests/KeyboardShortcutsTests.cpp +++ b/tests/KeyboardShortcutsTests.cpp @@ -92,8 +92,8 @@ void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes() void KeyboardShortcutsTests::theListIsSwiftsWithoutHide() { const std::vector &all = ShortcutDefinition::all(); - // Swift's 112 since 1.1.7, less Hide Compositor. - QCOMPARE(int(all.size()), 111); + // Swift's 113 with Ungroup Layers, less Hide Compositor. + QCOMPARE(int(all.size()), 112); QSet ids; for (const ShortcutDefinition &definition : all) ids.insert(definition.id()); diff --git a/tests/LayerContextMenuTests.cpp b/tests/LayerContextMenuTests.cpp index 4866771..f234d8e 100644 --- a/tests/LayerContextMenuTests.cpp +++ b/tests/LayerContextMenuTests.cpp @@ -22,6 +22,7 @@ private slots: void entriesFollowTheSelectionAndTheGates(); void thumbnailsAndEffectsChooseWhatTheMenuTargets(); void testContextMenuDuplicateFolderPreservesHierarchy(); + void aFoldersMenuOffersUngroup(); }; void LayerContextMenuTests::testContextMenuContainsCoreLayerActions() @@ -312,5 +313,30 @@ void LayerContextMenuTests::testContextMenuDuplicateFolderPreservesHierarchy() QCOMPARE(session->document().value().layers.size(), 2 * children + 2); } +void LayerContextMenuTests::aFoldersMenuOffersUngroup() +{ + const auto session = threeLayers(); + const QUuid lower = session->document().value().layers[0].id; + session->selectLayers({lower}, lower); + session->groupSelectedLayers(); + Shown shown(*session); + // The folder sits lowest; the top row is none. + session->selectLayer(std::nullopt); + QVERIFY(!titles(*shown.menu(0)).contains("Ungroup Layers")); + const int folder = int(shown.list.cells().size()) - 2; + QVERIFY(shown.row(folder).layerID() != lower); + std::unique_ptr menu = shown.menu(folder); + const QStringList entries = titles(*menu); + QCOMPARE(entries.indexOf("Ungroup Layers"), entries.indexOf("Group Selected Layers") + 1); + QVERIFY(item(*menu, "ungroupLayers").isEnabled()); + session->setIsProjectBusy(true); + QVERIFY(!item(*shown.menu(folder), "ungroupLayers").isEnabled()); + session->setIsProjectBusy(false); + item(*shown.menu(folder), "ungroupLayers").trigger(); + QCOMPARE(session->history.undoName(), QString("Ungroup Layers")); + QCOMPARE(session->document().value().layers.size(), size_t(3)); + QCOMPARE(session->selectedLayerIDs(), QSet{lower}); +} + QTEST_MAIN(LayerContextMenuTests) #include "LayerContextMenuTests.moc" diff --git a/tests/LayerDragGestureTests.cpp b/tests/LayerDragGestureTests.cpp index 990cec9..c905f0a 100644 --- a/tests/LayerDragGestureTests.cpp +++ b/tests/LayerDragGestureTests.cpp @@ -74,15 +74,25 @@ void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors() QCOMPARE(image(shown.list.cursorFor(folderBody, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); const QPoint folderStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4)); QCOMPARE(image(shown.list.cursorFor(folderStrip, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); - // A mask thumbnail copies, or loads with Ctrl; busy, nothing. + // Alt over a mask: an eye behind the pointer. session->selectLayer(shown.id(0)); session->addLayerMask(); LayerCell &top = *shown.list.cells().at(0); const QPoint mask = top.mapTo(&shown.list, top.maskThumbnail().geometry().center()); - QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio))); QCOMPARE(image(shown.list.cursorFor(mask, Qt::ControlModifier)), image(CanvasView::loadSelectionCursor(ratio))); + // The pointer's hot spot; the eye sits below right. + const QCursor eye = NativeLayerList::showMaskCursor(1); + QCOMPARE(eye.hotSpot(), CanvasView::duplicateCursor(1).hotSpot()); + QCOMPARE(eye.pixmap().size(), QSize(29, 32)); + const QImage drawn = image(eye); + QCOMPARE(drawn.pixelColor(22, 23), QColor(Qt::black)); + QVERIFY(drawn.pixelColor(21, 23).lightness() > 150); + QCOMPARE(drawn.pixelColor(25, 23).alpha(), 255); + QCOMPARE(drawn.copy(0, 0, 18, 18), image(CanvasView::duplicateCursor(1)).copy(0, 0, 18, 18)); + QCOMPARE(image(CanvasView::duplicateCursor(1)).pixelColor(23, 23).alpha(), 0); session->setIsImporting(true); - QCOMPARE(shown.list.cursorFor(mask, Qt::AltModifier).shape(), Qt::ArrowCursor); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio))); QCOMPARE(shown.list.cursorFor(mask, Qt::ControlModifier).shape(), Qt::ArrowCursor); QCOMPARE(shown.list.cursorFor(body, Qt::AltModifier).shape(), Qt::ArrowCursor); session->setIsImporting(false);