#include "MenuFixtures.h" #include "Rendering/EditorCanvas.h" #include "UI/NativeLayerList.h" #include #include #include // The Select and Image menus, the Edit menu's clipboard. class SelectMenuTests : public QObject { Q_OBJECT private slots: void selectEntriesFollowTheSelectionAndActOnIt(); void selectAllInAFieldIsTheFields(); void invertFollowsTheActiveLayerAndItsMask(); void levelsOpensOnPixelsAndRestsNewAndImport(); void hueSaturationOpensOnPixelsAndRestsLevels(); void filtersOpenFromTheImageAndFilterMenus(); void copyPasteAndLayerViaCopyFollowTheSelection(); void copyAndPasteInAFieldAreTheFields(); void contentAwareFillFollowsTheSelection(); void cutTheFillsAndClearFollowTheSelectionAndTheField(); void transformTheSelectionOrTheLayer(); }; void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt() { Bar bar; for (const char *name : {"deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection", "featherSelection"}) QVERIFY2(!bar.action(name).isEnabled(), name); // Select All never rests; without a canvas it does nothing. QVERIFY(bar.action("selectAll").isEnabled()); bar.action("selectAll").trigger(); QVERIFY(!bar.session().document()); bar.session().createDocument(50, 50, true); QVERIFY(bar.action("subject").isEnabled()); QVERIFY(bar.action("selectAll").isEnabled()); // A blank layer has no pixels and no mask. QVERIFY(!bar.action("layerPixels").isEnabled() && !bar.action("maskBlackAreas").isEnabled()); QVERIFY(!bar.action("deselect").isEnabled() && !bar.action("inverse").isEnabled()); bar.action("selectAll").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Select All")); QVERIFY(bar.action("deselect").isEnabled() && bar.action("inverse").isEnabled()); QVERIFY(bar.action("expandSelection").isEnabled() && bar.action("contractSelection").isEnabled()); QCOMPARE(bar.action("expandSelection").text(), QString("Expand…")); QCOMPARE(bar.action("contractSelection").text(), QString("Contract…")); QCOMPARE(bar.action("featherSelection").text(), QString("Feather…")); // Each asks for its amount, which the sheet confirms. bar.action("contractSelection").trigger(); QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::contract)); QVERIFY(!bar.action("contractSelection").isEnabled() && !bar.action("subject").isEnabled()); bar.session().confirmSelectionAmount(7); QCOMPARE(bar.session().history.undoName(), QString("Contract Selection")); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(7, 7, 36, 36)); bar.action("expandSelection").trigger(); QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::expand)); bar.session().confirmSelectionAmount(3); QCOMPARE(bar.session().history.undoName(), QString("Expand Selection")); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(4, 4, 42, 42)); bar.action("featherSelection").trigger(); QCOMPARE(bar.session().selectionAmountOperation(), std::optional(SelectionAmountOperation::feather)); bar.session().confirmSelectionAmount(2); QCOMPARE(bar.session().history.undoName(), QString("Feather Selection")); bar.action("inverse").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Inverse")); bar.action("deselect").trigger(); QVERIFY(!bar.session().selection().has_value()); // Pixels and a mask: their entries load them. bar.session().insert(white()); QVERIFY(bar.action("layerPixels").isEnabled() && !bar.action("maskBlackAreas").isEnabled()); bar.action("layerPixels").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Load Layer Selection")); bar.session().addLayerMask(false); QVERIFY(bar.action("maskBlackAreas").isEnabled()); // The same outline again records nothing; deselected, it loads. bar.action("maskBlackAreas").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Add Hide-All Mask")); bar.session().deselect(); bar.action("maskBlackAreas").trigger(); QCOMPARE(bar.session().history.undoName(), QString("Load Mask Selection")); // Busy, every entry waits; a draft holds Expand and Contract. bar.session().setIsProjectBusy(true); for (const char *name : {"deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection", "featherSelection"}) QVERIFY2(!bar.action(name).isEnabled(), name); QVERIFY(bar.action("selectAll").isEnabled()); bar.session().setIsProjectBusy(false); // Subject runs the model on what the canvas shows. bar.action("subject").trigger(); QVERIFY(bar.session().isProjectBusy()); QTRY_VERIFY_WITH_TIMEOUT(!bar.session().isProjectBusy(), 20000); // A white layer has no subject, and says so. QVERIFY(bar.session().brushError().has_value()); bar.session().setBrushError(std::nullopt); bar.session().selectTool(NavigationTool::lasso); bar.session().beginLasso(QPointF(1, 1), SelectionMode::replace); QVERIFY(!bar.action("expandSelection").isEnabled() && bar.action("deselect").isEnabled()); } void SelectMenuTests::selectAllInAFieldIsTheFields() { Bar bar; bar.window.show(); QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); // Without a canvas the entry selects the welcome's field. QLineEdit &width = *bar.window.findChild("widthInput"); QTRY_VERIFY(width.hasFocus()); width.setText(QStringLiteral("640")); width.deselect(); QVERIFY(bar.action("selectAll").isEnabled()); bar.action("selectAll").trigger(); QCOMPARE(width.selectedText(), QString("640")); bar.session().createDocument(50, 50, true); QTRY_VERIFY(bar.action("selectAll").isEnabled()); // A rename in progress: the editor's text, never the canvas. bar.session().setRenamingLayerID(bar.session().activeLayerID()); QLineEdit *editor = nullptr; QTRY_VERIFY((editor = bar.window.findChild("layerNameEditor")) && editor->hasFocus()); editor->deselect(); QVERIFY(editor->selectedText().isEmpty()); bar.action("selectAll").trigger(); QCOMPARE(editor->selectedText(), QString("Layer 1")); QVERIFY(!bar.session().selection().has_value()); QTest::keyClick(editor, Qt::Key_Escape); QTRY_VERIFY(!bar.session().renamingLayerID().has_value()); // The list holds the keys: the canvas, never every layer. bar.session().addBlankLayer(); NativeLayerList *list = bar.window.findChild(); list->setFocus(); QTRY_VERIFY(list->hasFocus()); const QSet selected = bar.session().selectedLayerIDs(); const std::optional active = bar.session().activeLayerID(); QCOMPARE(selected.size(), 1); QTest::keyClick(list, Qt::Key_A, Qt::ControlModifier); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(0, 0, 50, 50)); QCOMPARE(bar.session().history.undoName(), QString("Select All")); QCOMPARE(bar.session().selectedLayerIDs(), selected); QCOMPARE(bar.session().activeLayerID(), active); QVERIFY(list->hasFocus()); // Elsewhere it selects the canvas too. bar.session().deselect(); bar.window.findChild()->setFocus(); QTRY_VERIFY(!list->hasFocus()); bar.action("selectAll").trigger(); QVERIFY(bar.session().selection().has_value()); } void SelectMenuTests::invertFollowsTheActiveLayerAndItsMask() { Bar bar; QAction &invert = bar.action("invert"); QVERIFY(!invert.isEnabled()); QCOMPARE(invert.text(), QString("Invert")); bar.session().createDocument(8, 8, true); QVERIFY(!invert.isEnabled()); bar.session().insert(white()); QVERIFY(invert.isEnabled()); bar.session().addLayerMask(); QCOMPARE(invert.text(), QString("Invert Mask")); invert.trigger(); QTRY_VERIFY(!bar.session().isProjectBusy()); QCOMPARE(bar.session().history.undoName(), QString("Invert Mask")); QCOMPARE(int(bar.session().activeLayer().value().mask.value().asset.image().constScanLine(0)[0]), 0); bar.session().selectLayerTarget(bar.session().activeLayerID().value(), false); QCOMPARE(invert.text(), QString("Invert")); invert.trigger(); QTRY_VERIFY(!bar.session().isProjectBusy()); QCOMPARE(bar.session().history.undoName(), QString("Invert")); QCOMPARE(bar.session().activeLayer().value().asset.value().image().pixelColor(0, 0), QColor(Qt::black)); bar.session().setIsImporting(true); QVERIFY(!invert.isEnabled()); } void SelectMenuTests::levelsOpensOnPixelsAndRestsNewAndImport() { Bar bar; QAction &levels = bar.action("levels"), &import = bar.action("importImages"), &undo = bar.action("undo"); QAction &fresh = *bar.window.findChild("newCanvasToolbar"); QCOMPARE(levels.text(), QString("Levels…")); QVERIFY(!levels.isEnabled()); // A blank layer holds no pixels to adjust. bar.session().createDocument(8, 8, true); QVERIFY(!levels.isEnabled()); bar.session().insert(white()); QVERIFY(levels.isEnabled() && import.isEnabled() && fresh.isEnabled()); levels.trigger(); QVERIFY(bar.session().levels()); // Open, it rests itself, New and Import; Undo is bare. QVERIFY(!levels.isEnabled() && !import.isEnabled() && !fresh.isEnabled()); QCOMPARE(undo.text(), QString("Undo")); QVERIFY(!undo.isEnabled()); bar.session().cancelLevels(); QVERIFY(levels.isEnabled() && import.isEnabled() && fresh.isEnabled()); QCOMPARE(undo.text(), QString("Undo Import Image")); bar.session().addLayerMask(); QVERIFY(!levels.isEnabled()); } void SelectMenuTests::hueSaturationOpensOnPixelsAndRestsLevels() { Bar bar; QAction &hue = bar.action("hueSaturation"), &levels = bar.action("levels"), &import = bar.action("importImages"); QCOMPARE(hue.text(), QString("Hue/Saturation…")); QVERIFY(!hue.isEnabled()); bar.session().createDocument(8, 8, true); QVERIFY(!hue.isEnabled()); bar.session().insert(white()); QVERIFY(hue.isEnabled() && levels.isEnabled()); hue.trigger(); const QUuid opened = bar.session().hueSaturation().value().id; // Open, it rests Levels; its own entry stays, as Swift's. QVERIFY(!levels.isEnabled() && hue.isEnabled() && import.isEnabled()); hue.trigger(); QCOMPARE(bar.session().hueSaturation().value().id, opened); bar.session().cancelHueSaturation(); QVERIFY(levels.isEnabled()); // Open Levels rests it in turn. levels.trigger(); QVERIFY(!hue.isEnabled()); bar.session().cancelLevels(); QVERIFY(hue.isEnabled()); } void SelectMenuTests::filtersOpenFromTheImageAndFilterMenus() { Bar bar; const std::pair entries[] = { {"curves", FilterKind::curves}, {"blackWhite", FilterKind::blackWhite}, {"colorBalance", FilterKind::colorBalance}, {"exposure", FilterKind::exposure}, {"gradientMap", FilterKind::gradientMap}, {"grain", FilterKind::grain}, {"gaussianBlur", FilterKind::gaussianBlur}, {"motionBlur", FilterKind::motionBlur}, {"addNoise", FilterKind::addNoise}, {"lensCorrection", FilterKind::lensCorrection}, {"removeBackground", FilterKind::removeBackground}}; for (const auto &[name, kind] : entries) { // A menu doubles an ampersand, else read as a mnemonic. QCOMPARE(bar.action(name).text(), rawValue(kind).replace("&", "&&") + QStringLiteral("…")); QVERIFY2(!bar.action(name).isEnabled(), name); } bar.session().createDocument(8, 8, true); bar.session().insert(white()); // Each opens its filter; open, a filter rests every entry. for (const auto &[name, kind] : entries) { QVERIFY2(bar.action(name).isEnabled(), name); bar.action(name).trigger(); QCOMPARE(bar.session().filterEdit().value().kind, kind); for (const auto &[other, unused] : entries) QVERIFY2(!bar.action(other).isEnabled(), other); bar.session().cancelFilter(); } // Hue/Saturation open rests them too, as Levels. bar.session().beginHueSaturation(); for (const auto &[name, kind] : entries) QVERIFY2(!bar.action(name).isEnabled(), name); bar.session().cancelHueSaturation(); QVERIFY(bar.action("curves").isEnabled()); } void SelectMenuTests::copyPasteAndLayerViaCopyFollowTheSelection() { Bar bar; QAction © = bar.action("copy"), &merged = bar.action("copyMerged"), &paste = bar.action("paste"), &via = bar.action("layerViaCopy"); // Copy and Paste are always live and check when chosen. QVERIFY(copy.isEnabled() && paste.isEnabled() && !merged.isEnabled() && !via.isEnabled()); copy.trigger(); paste.trigger(); QVERIFY(!bar.session().document().has_value()); bar.session().createDocument(8, 8, true); QCOMPARE(via.text(), QString("Duplicate Layer")); QVERIFY(via.isEnabled() && !merged.isEnabled()); // A blank layer's selection has no pixels for a layer. bar.session().selectAll(); QVERIFY(!via.isEnabled()); bar.session().deselect(); QVERIFY(via.isEnabled()); bar.session().insert(white()); QVERIFY(merged.isEnabled()); bar.session().selectAll(); QCOMPARE(via.text(), QString("Layer via Copy")); QVERIFY(via.isEnabled()); copy.trigger(); QVERIFY(bar.session().pixelClipboard().has_value()); paste.trigger(); QCOMPARE(bar.session().history.undoName(), QString("Paste")); QCOMPARE(int(bar.session().document().value().layers.size()), 3); bar.session().selectAll(); via.trigger(); QCOMPARE(bar.session().history.undoName(), QString("Layer via Copy")); merged.trigger(); QVERIFY(bar.session().pixelClipboard().has_value()); bar.session().setIsProjectBusy(true); QVERIFY(!merged.isEnabled() && !via.isEnabled() && copy.isEnabled() && paste.isEnabled()); } void SelectMenuTests::copyAndPasteInAFieldAreTheFields() { Bar bar; bar.window.show(); QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); QLineEdit &width = *bar.window.findChild("widthInput"); QTRY_VERIFY(width.hasFocus()); width.setText("77"); width.selectAll(); bar.action("copy").trigger(); width.setText(""); bar.action("paste").trigger(); QCOMPARE(width.text(), QString("77")); QVERIFY(!bar.session().pixelClipboard().has_value()); } void SelectMenuTests::contentAwareFillFollowsTheSelection() { Bar bar; QAction &fill = bar.action("contentAwareFill"); QCOMPARE(fill.text(), QString("Content-Aware Fill…")); QVERIFY(!fill.isEnabled()); bar.session().createDocument(8, 8, true); bar.session().insert(white()); QVERIFY(!fill.isEnabled()); bar.session().selectAll(); QVERIFY(fill.isEnabled()); fill.trigger(); QVERIFY(bar.session().filterEdit().has_value() && !fill.isEnabled()); bar.session().cancelFilter(); QVERIFY(fill.isEnabled()); bar.session().setIsProjectBusy(true); QVERIFY(!fill.isEnabled()); } void SelectMenuTests::cutTheFillsAndClearFollowTheSelectionAndTheField() { Bar bar; QAction &cut = bar.action("cut"), &foreground = bar.action("fillForeground"), &background = bar.action("fillBackground"), &clear = bar.action("clearSelectionPixels"); QCOMPARE(foreground.text(), QString("Fill with Foreground Color")); QCOMPARE(background.text(), QString("Fill with Background Color")); QCOMPARE(clear.text(), QString("Clear Selection Pixels")); // Cut checks when chosen; the others follow the pixels. QVERIFY(cut.isEnabled() && !foreground.isEnabled() && !background.isEnabled() && !clear.isEnabled()); cut.trigger(); QVERIFY(!bar.session().document().has_value()); bar.session().createDocument(8, 8, true); bar.session().insert(white()); QVERIFY(foreground.isEnabled() && background.isEnabled() && !clear.isEnabled()); // A rename closes the fills, as Swift's `canEditPixels` does. bar.session().setRenamingLayerID(bar.session().activeLayerID()); QVERIFY(!foreground.isEnabled() && !background.isEnabled()); bar.session().setRenamingLayerID(std::nullopt); // In a text field the entries are its own deletions. bar.window.show(); QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); QLineEdit &percent = *bar.window.findChild("opacityPercent"); percent.setFocus(); QTRY_VERIFY(percent.hasFocus()); percent.setText(QStringLiteral("45")); percent.end(false); foreground.trigger(); QCOMPARE(percent.text(), QString("")); percent.setText(QStringLiteral("45")); percent.end(false); background.trigger(); QCOMPARE(percent.text(), QString("")); // At the start nothing lies behind the caret. percent.setText(QStringLiteral("45")); percent.home(false); foreground.trigger(); QCOMPARE(percent.text(), QString("45")); background.trigger(); QCOMPARE(percent.text(), QString("45")); // Mid-text they delete backwards; a selection goes alone. percent.setCursorPosition(1); background.trigger(); QCOMPARE(percent.text(), QString("5")); percent.setText(QStringLiteral("1245")); percent.setSelection(1, 2); foreground.trigger(); QCOMPARE(percent.text(), QString("15")); percent.setText(QStringLiteral("1245")); percent.setSelection(3, -2); background.trigger(); QCOMPARE(percent.text(), QString("15")); QCOMPARE(bar.session().history.undoName(), QString("Import Image")); percent.clearFocus(); QTRY_VERIFY(!percent.hasFocus()); // Without a selection Cut has nothing, whole layer or not. QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("nothing to cut"))); cut.trigger(); bar.session().selectAll(); QVERIFY(clear.isEnabled()); cut.trigger(); QTRY_COMPARE(bar.session().history.undoName(), QString("Clear")); QVERIFY(bar.session().pixelClipboard().has_value()); bar.session().undo(); clear.trigger(); QTRY_COMPARE(bar.session().history.undoName(), QString("Clear")); background.trigger(); QTRY_COMPARE(bar.session().history.undoName(), QString("Fill")); } void SelectMenuTests::transformTheSelectionOrTheLayer() { Bar bar; QAction &transform = bar.action("transformLayer"); QVERIFY(!transform.isEnabled()); bar.session().createDocument(8, 8, true); bar.session().insert(white()); QCOMPARE(transform.text(), QString("Transform Layer")); QVERIFY(transform.isEnabled()); bar.session().selectAll(); QCOMPARE(transform.text(), QString("Transform Selection")); transform.trigger(); QTRY_VERIFY(bar.session().transformEdit().has_value() && bar.session().transformEdit().value().floating); // Floating, neither transform may start again. QVERIFY(!transform.isEnabled()); QCOMPARE(transform.text(), QString("Transform Layer")); bar.session().cancelTransform(); bar.session().deselect(); transform.trigger(); QVERIFY(bar.session().transformEdit().has_value() && !bar.session().transformEdit().value().floating); } QTEST_MAIN(SelectMenuTests) #include "SelectMenuTests.moc"