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:
1 parent
3f2b99f045
commit
6ea248367d
12 files changed
+271
-12
No files matched your search
@@ -7,6 +7,9 @@ public:
|
||||
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
|
||||
void extendSelection(QUuid id);
|
||||
void groupSelectedLayers();
|
||||
// The active layer is a folder: something to unwrap.
|
||||
bool canUngroupLayers() const;
|
||||
void ungroupLayers();
|
||||
std::vector<LayerHierarchy::Entry> layerRows() const;
|
||||
bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const;
|
||||
bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
|
||||
|
||||
@@ -317,6 +317,44 @@ void EditorSession::groupSelectedLayers()
|
||||
endEdit();
|
||||
}
|
||||
|
||||
bool EditorSession::canUngroupLayers() const
|
||||
{
|
||||
const std::optional<ImageLayer> 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<ImageLayer> children;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (layer.parentID == group.id) {
|
||||
children.push_back(layer);
|
||||
children.back().parentID = group.parentID;
|
||||
}
|
||||
}
|
||||
std::vector<ImageLayer> layers;
|
||||
QSet<QUuid> 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<LayerHierarchy::Entry> EditorSession::layerRows() const
|
||||
{
|
||||
std::vector<ProjectLayerRecord> records;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -134,7 +134,7 @@ const std::vector<ShortcutDefinition> &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<std::pair<const char *, QString>> tools{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QScrollBar>
|
||||
#include <cmath>
|
||||
|
||||
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))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "UI/NativeLayerList.h"
|
||||
#include <QMenu>
|
||||
#include <algorithm>
|
||||
|
||||
// Swift's table-level context menu (1.2.5): entries and validation.
|
||||
std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
|
||||
@@ -40,6 +41,9 @@ std::unique_ptr<QMenu> 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(); });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user