#include "ContentView.h" #include "MenuFixtures.h" #include "Rendering/EditorCanvas.h" #include "UI/EffectsSheet.h" #include "UI/LayerIcons.h" #include "UI/LayersPanel.h" #include #include #include #include #include #include #include #include #include #include #include // Layer effects in the window: footer, panel, words. namespace { template Widget &find(QWidget &root, const char *name) { Widget *found = root.findChild(QString::fromLatin1(name)); if (!found) throw std::runtime_error(std::string("no widget named ") + name); return *found; } // A white layer in a shown editor. struct Editor { EditorSession session; ContentView view{session}; QUuid layer; Editor() { session.createDocument(40, 30); QImage white(40, 30, QImage::Format_RGBA8888_Premultiplied); white.fill(Qt::white); session.insert(ImportedImage(white, white, QStringLiteral("White"))); layer = session.activeLayerID().value(); view.show(); } // The panel's shown sheet; a closed panel's goes later. EffectsSheet *sheet() { for (EffectsSheet *each : view.findChildren()) { if (each->isVisible()) return each; } return nullptr; } QDialog &panel() { return *qobject_cast(sheet()->window()); } // The shown panel holds the keys, as users find it. bool keyed() { return QTest::qWaitFor([&] { return sheet() && QApplication::activeWindow() == &panel(); }); } LayerEffects effects() const { return session.activeLayer().value().effects.value(); } }; // Types into a field and stays there. void typing(QLineEdit &field, const QString &text) { field.setFocus(); field.selectAll(); QTest::keyClicks(&field, text); } // Types into a field, then leaves it. void type(QLineEdit &field, const QString &text) { typing(field, text); field.clearFocus(); } // Counts the paints a widget receives. struct Painted : QObject { int &count; explicit Painted(int &count) : count(count) {} bool eventFilter(QObject *, QEvent *event) override { count += event->type() == QEvent::Paint; return false; } }; bool inked(const QImage &image, int x, int y) { return qAlpha(image.pixel(x, y)) > 128; } } class EffectsPanelTests : public QObject { Q_OBJECT private slots: void theFooterAddsEachEffectAndOpensItsPanel(); void theSheetEditsItsEffect(); void aWheelOnTheSliderReplacesTheTyping(); void theSheetKeepsSwiftsMeasures(); void eachKindHasSwiftsControls_data(); void eachKindHasSwiftsControls(); void aMissingEffectHidesItsControls(); void cancelAndEscapePutTheEffectBack(); void anEffectThatGoesTakesItsPanel(); void deletingNamesAChosenEffect(); void aCanvasPressLetsGoOfTheChosenEffect(); }; void EffectsPanelTests::theFooterAddsEachEffectAndOpensItsPanel() { Editor editor; auto &button = find(editor.view, "layerEffects"); QCOMPARE(button.toolTip(), QString("Layer effects: stroke and drop shadow")); QCOMPARE(button.accessibleName(), QString("Layer effects")); QCOMPARE(button.popupMode(), QToolButton::InstantPopup); // Swift's sparkles, in the footer's ink: three four-pointed stars. const LayersPanel &panel = editor.view.layersPanel(); const QImage icon = LayerIcons::pixmap(LayerIcon::sparkles, 16, panel.palette().color(QPalette::PlaceholderText), panel.devicePixelRatio()).toImage(); QCOMPARE(button.icon().pixmap(QSize(16, 16), panel.devicePixelRatio()).toImage().convertToFormat(icon.format()), icon); const QImage glyph = LayerIcons::pixmap(LayerIcon::sparkles, 18, Qt::black, 1).toImage(); QVERIFY(inked(glyph, 7, 10) && inked(glyph, 13, 4) && inked(glyph, 14, 13) && !inked(glyph, 2, 2) && !inked(glyph, 16, 9)); QStringList names; for (const QAction *entry : button.menu()->actions()) names << entry->text(); QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…"})); QVERIFY(button.isEnabled()); button.menu()->actions()[0]->trigger(); QVERIFY((editor.session.effectsEditing() == LayerEffectSelection{editor.layer, LayerEffectKind::stroke})); QCOMPARE(editor.panel().objectName(), QString("effectsPanel")); QCOMPARE(editor.panel().windowTitle(), QString("Stroke")); // Another kind cancels this panel: the new stroke goes. button.menu()->actions()[1]->trigger(); QCOMPARE(editor.panel().windowTitle(), QString("Drop Shadow")); QVERIFY(!editor.effects().stroke && editor.effects().shadow); editor.session.finishEffectsEditing(true); QTRY_VERIFY(!editor.sheet()); // A layer without pixels takes none. editor.session.addBlankLayer(); QVERIFY(!button.isEnabled()); } void EffectsPanelTests::theSheetEditsItsEffect() { Editor editor; editor.session.addEffect(LayerEffectKind::stroke); QVERIFY(editor.keyed()); EffectsSheet &sheet = *editor.sheet(); auto &size = find(sheet, "sizeField"); auto &slider = find(sheet, "sizeSlider"); QCOMPARE(size.text(), QString("4")); type(size, "12"); QCOMPARE(editor.effects().stroke->size, 12.0); QCOMPARE(slider.value(), 22); // No finite number changes nothing; the field goes back. type(size, "abc"); QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); type(size, "inf"); QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); // Up steps one, Shift ten; past the range, clamped. QTest::keyClick(&size, Qt::Key_Up); QCOMPARE(editor.effects().stroke->size, 13.0); QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier); QCOMPARE(editor.effects().stroke->size, 23.0); // A step replaces pending typing, even where it clamps. typing(size, "12"); QTest::keyClick(&size, Qt::Key_Up); QVERIFY(editor.effects().stroke->size == 24 && size.text() == "24" && !size.isModified()); size.clearFocus(); QCOMPARE(editor.effects().stroke->size, 24.0); type(size, "900"); QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500"); typing(size, "12"); QTest::keyClick(&size, Qt::Key_Up); QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500" && !size.isModified()); size.clearFocus(); QCOMPARE(editor.effects().stroke->size, 500.0); // The slider sets any value; the field rounds, ties even. slider.setValue(500); QVERIFY(editor.effects().stroke->size == 250.5 && size.text() == "250"); // Leaving an untouched field keeps the exact value. size.setFocus(); size.clearFocus(); QCOMPARE(editor.effects().stroke->size, 250.5); // Typing waits while other changes arrive. auto &opacity = find(sheet, "opacityField"); QCOMPARE(opacity.text(), QString("100")); typing(size, "3"); find(sheet, "opacitySlider").setValue(500); QVERIFY(editor.effects().stroke->opacity == 0.5 && opacity.text() == "50" && size.text() == "3"); size.clearFocus(); QCOMPARE(editor.effects().stroke->size, 3.0); // Inside or Outside, as Swift's segments, following an undo. const QList position = find(sheet, "strokePosition").findChildren(); QVERIFY(position.size() == 2 && position[0]->text() == "Outside" && position[1]->text() == "Inside" && position[0]->isChecked()); position[1]->click(); QVERIFY(editor.effects().stroke->inside && position[1]->isChecked()); editor.session.undo(); QVERIFY(!editor.effects().stroke->inside && position[0]->isChecked()); position[1]->click(); // The swatch opens the picker, whose colour shows at once. auto &swatch = find(sheet, "effectColor"); QVERIFY(swatch.toolTip() == "Stroke color" && swatch.accessibleName() == "Stroke color" && swatch.size() == QSize(36, 18)); swatch.click(); QVERIFY(editor.session.colorPicker().value().target.kind == ColorPickerTarget::Kind::effect); QTest::qWait(100); int paints = 0; Painted counter(paints); swatch.installEventFilter(&counter); editor.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); QCOMPARE(editor.effects().stroke->color(), (PaletteColor{1, 0, 0})); QTRY_VERIFY(paints > 0); editor.session.closeColorPicker(true); // Return commits, then goes on to OK, keeping everything. typing(size, "7"); QTest::keyClick(&size, Qt::Key_Return); QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == 7 && editor.effects().stroke->inside); QTRY_VERIFY(!editor.sheet()); } void EffectsPanelTests::aWheelOnTheSliderReplacesTheTyping() { Editor editor; editor.session.addEffect(LayerEffectKind::stroke); QVERIFY(editor.keyed()); EffectsSheet &sheet = *editor.sheet(); auto &size = find(sheet, "sizeField"); auto &slider = find(sheet, "sizeSlider"); // A wheel moves the slider; the field keeps the keys. typing(size, "12"); QWheelEvent wheel(QPointF(10, 5), slider.mapToGlobal(QPointF(10, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); QApplication::sendEvent(&slider, &wheel); const double wheeled = 1 + 499.0 * (6 - QApplication::wheelScrollLines()) / 1000; QCOMPARE(editor.effects().stroke->size, wheeled); QVERIFY(size.hasFocus() && size.text() == QString::number(std::lround(wheeled)) && !size.isModified()); // Return then keeps the slider's value, going on to OK. QTest::keyClick(&size, Qt::Key_Return); QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == wheeled); } void EffectsPanelTests::theSheetKeepsSwiftsMeasures() { Editor editor; editor.session.addEffect(LayerEffectKind::stroke); EffectsSheet &sheet = *editor.sheet(); QTRY_VERIFY(sheet.isVisible()); QCOMPARE(sheet.width(), 340); auto &slider = find(sheet, "sizeSlider"); auto &field = find(sheet, "sizeField"); QWidget &row = *slider.parentWidget(); QLabel *title = nullptr, *unit = nullptr; for (QLabel *label : row.findChildren()) { if (label->text() == "Size") title = label; if (label->text() == "px") unit = label; } // Title 64, slider 130, field 48, then the unit. QVERIFY(title && unit && title->buddy() == &slider); QVERIFY(title->x() == 0 && title->width() == 64 && slider.x() == 74 && slider.width() == 130); QVERIFY(field.x() == 214 && field.width() == 48 && unit->x() == 264); QVERIFY(field.alignment() == Qt::AlignRight && field.accessibleName() == "Size" && field.placeholderText() == "Size"); // Twenty points round the sheet, sixteen between its rows. const QWidget &colour = *find(sheet, "effectColor").parentWidget(); QCOMPARE(colour.x(), 20); QCOMPARE(row.y() - colour.geometry().bottom() - 1, 16); QCOMPARE(colour.findChild()->width(), 64); // Cancel, then the default OK ten points on. const QPushButton &cancel = find(sheet, "effectsCancel"), &ok = find(sheet, "effectsOK"); QVERIFY(ok.x() - cancel.geometry().right() - 1 == 10 && ok.geometry().right() == 319); QVERIFY(ok.isDefault() && !cancel.autoDefault()); // Swift's segments touch. const QList position = find(sheet, "strokePosition").findChildren(); QCOMPARE(position[1]->x(), position[0]->geometry().right() + 1); } void EffectsPanelTests::eachKindHasSwiftsControls_data() { QTest::addColumn("kind"); QTest::addColumn("headline"); QTest::addColumn("fields"); const QStringList shadow{"Opacity:0:100", "Angle:-180:180", "Distance:0:300", "Blur:0:300"}; QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:1:500", "Opacity:0:100"}; QTest::newRow("shadow") << LayerEffectKind::shadow << QString("Drop Shadow") << shadow; QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100"}; QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow") << shadow; } void EffectsPanelTests::eachKindHasSwiftsControls() { QFETCH(LayerEffectKind, kind); QFETCH(QString, headline); QFETCH(QStringList, fields); Editor editor; editor.session.addEffect(kind); QVERIFY(editor.keyed()); EffectsSheet &sheet = *editor.sheet(); QCOMPARE(editor.panel().windowTitle(), headline); const QLabel *head = nullptr; for (const QLabel *label : sheet.findChildren()) { if (label->text() == headline) head = label; } QVERIFY(head && head->font().pixelSize() == 13 && head->font().weight() == QFont::DemiBold); // Swift's fields in order, each clamped to its range's ends. QStringList names; for (const QLineEdit *field : sheet.findChildren()) names << field->accessibleName(); QStringList titles; for (const QString &spec : fields) { const QStringList parts = spec.split(':'); titles << parts[0]; auto &field = *sheet.findChild(parts[0].toLower() + "Field"); type(field, "99999"); QCOMPARE(field.text(), parts[2]); type(field, "-99999"); QCOMPARE(field.text(), parts[1]); } QCOMPARE(names, titles); } void EffectsPanelTests::aMissingEffectHidesItsControls() { Editor editor; editor.session.addEffect(LayerEffectKind::stroke); // For a kind the layer lacks, the headline alone. EffectsSheet other(editor.session, LayerEffectKind::shadow); QVERIFY(!find(other, "blurSlider").isVisibleTo(&other) && !find(other, "effectColor").isVisibleTo(&other)); editor.session.changeEffects([](LayerEffects &effects) { effects.shadow = ShadowEffect(); }); QVERIFY(find(other, "blurSlider").isVisibleTo(&other) && find(other, "effectColor").isVisibleTo(&other)); } void EffectsPanelTests::cancelAndEscapePutTheEffectBack() { Editor editor; // Return on a focused Cancel still means OK, Swift's default. editor.session.addEffect(LayerEffectKind::shadow); QVERIFY(editor.keyed()); type(find(*editor.sheet(), "angleField"), "-45"); auto &cancel = find(*editor.sheet(), "effectsCancel"); cancel.setFocus(); QTest::keyClick(&cancel, Qt::Key_Return); QVERIFY(editor.effects().shadow->angle == -45 && !editor.session.effectsEditing()); // An existing shadow: Cancel puts it back as it was. editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true); QCOMPARE(find(*editor.sheet(), "distanceField").text(), QString("20")); find(*editor.sheet(), "distanceSlider").setValue(400); QCOMPARE(editor.effects().shadow->distance, 120.0); find(*editor.sheet(), "effectsCancel").click(); QVERIFY(editor.effects().shadow->distance == 20 && !editor.session.effectsEditing()); // A new overlay's panel: Escape takes the overlay away again. editor.session.addEffect(LayerEffectKind::colorOverlay); QVERIFY(editor.effects().colorOverlay); QTest::keyClick(&editor.panel(), Qt::Key_Escape); QVERIFY(!editor.effects().colorOverlay && editor.effects().shadow && !editor.session.effectsEditing()); } void EffectsPanelTests::anEffectThatGoesTakesItsPanel() { Editor editor; editor.session.addEffect(LayerEffectKind::stroke); editor.session.openEffectColorPicker(LayerEffectKind::stroke); QVERIFY(editor.sheet() && editor.session.colorPicker()); // Undoing the stroke takes its picker and its panel. editor.session.undo(); QVERIFY(!editor.session.activeLayer().value().effects); QVERIFY(!editor.session.colorPicker() && !editor.session.effectsEditing() && !editor.session.effectsEditingOriginal()); QTRY_VERIFY(!editor.sheet()); // A palette's picker stays: only an effect's goes with it. editor.session.addEffect(LayerEffectKind::stroke); editor.session.openColorPicker(false); QVERIFY(editor.session.colorPicker().value().target.kind == ColorPickerTarget::Kind::palette); editor.session.undo(); QVERIFY(editor.session.colorPicker() && !editor.session.effectsEditing()); editor.session.closeColorPicker(false); // Its kind gone while another stays closes the panel too. LayerEffects shadow; shadow.shadow = ShadowEffect(); editor.session.setEffects(shadow); editor.session.addEffect(LayerEffectKind::stroke); editor.session.undo(); QVERIFY(editor.effects().shadow && !editor.effects().stroke && !editor.session.effectsEditing()); // So does its layer, deleted. editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true); editor.session.dropEffectSelection(); editor.session.deleteLayerOrMask(); QVERIFY(editor.session.document().value().layers.empty() && !editor.session.effectsEditing()); QTRY_VERIFY(!editor.sheet()); } void EffectsPanelTests::deletingNamesAChosenEffect() { Bar bar; EditorSession &session = bar.session(); session.createDocument(4, 4); session.insert(white()); LayerEffects effects; effects.stroke = StrokeEffect(); session.setEffects(effects); auto &trash = find(bar.window, "deleteLayer"); QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); session.selectEffect(LayerEffectKind::stroke, session.activeLayerID().value()); QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Stroke")); QVERIFY(trash.toolTip() == "Delete selected effect" && trash.accessibleName() == "Delete selected effect"); // A choice an undo took away names the layer again. session.undo(); QVERIFY(session.effectSelection() && !session.selectedEffect()); QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); session.redo(); session.selectEffect(LayerEffectKind::stroke, session.activeLayerID().value()); // Delete takes the effect; the layer stays. bar.action("deleteLayer").trigger(); QVERIFY(!session.activeLayer().value().effects && session.document().value().layers.size() == 1); QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); } void EffectsPanelTests::aCanvasPressLetsGoOfTheChosenEffect() { Editor editor; LayerEffects effects; effects.stroke = StrokeEffect(); editor.session.setEffects(effects); editor.session.selectEffect(LayerEffectKind::stroke, editor.layer); CanvasView &canvas = *editor.view.findChild(); const QPoint middle = canvas.rect().center(); // Another button keeps it; the left lets go before guards. QTest::mouseClick(&canvas, Qt::RightButton, {}, middle); QVERIFY(editor.session.effectSelection()); editor.session.setIsProjectBusy(true); QTest::mouseClick(&canvas, Qt::LeftButton, {}, middle); QVERIFY(!editor.session.effectSelection()); editor.session.setIsProjectBusy(false); } QTEST_MAIN(EffectsPanelTests) #include "EffectsPanelTests.moc"