Layer › Ungroup Layers (Ctrl+Shift+G), in a folder's menu too; Alt over a mask shows the eye pointer (13.15)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 16:22:27 -04:00
1 parent 3f2b99f045
commit 6ea248367d
12 files changed
+271 -12

No files matched your search

+11 -4
View File
@@ -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<QUuid> 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);
}
+135
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include "IO/ImageResizer.h"
#include <QTemporaryDir>
#include <QSignalSpy>
#include <QtTest>
// 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<QUuid> 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<QUuid>{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<QUuid>{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"
+2 -2
View File
@@ -92,8 +92,8 @@ void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes()
void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
{
const std::vector<ShortcutDefinition> &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<QString> ids;
for (const ShortcutDefinition &definition : all)
ids.insert(definition.id());
+26
View File
@@ -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<QMenu> 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<QUuid>{lower});
}
QTEST_MAIN(LayerContextMenuTests)
#include "LayerContextMenuTests.moc"
+13 -3
View File
@@ -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);