A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
365 lines
16 KiB
C++
365 lines
16 KiB
C++
#include "BrushCanvasFixtures.h"
|
||
#include "UI/GradientControls.h"
|
||
#include "UI/LassoControls.h"
|
||
#include "Rendering/TransformOverlay.h"
|
||
#include <numbers>
|
||
#include <QCheckBox>
|
||
#include <QComboBox>
|
||
#include <QPushButton>
|
||
#include <QSlider>
|
||
#include <QToolButton>
|
||
|
||
// Swift's gradient on the canvas: drag, ends, keys, bar.
|
||
namespace {
|
||
void gradient(Canvas &shown)
|
||
{
|
||
shown.session.addBlankLayer();
|
||
shown.session.selectTool(NavigationTool::gradient);
|
||
shown.canvas->synchronizeDisplay();
|
||
}
|
||
|
||
QColor shownAt(Canvas &shown, int x, int y)
|
||
{
|
||
return shown.canvas->grab().toImage().pixelColor(x, y);
|
||
}
|
||
|
||
template <typename Widget> Widget &find(QWidget &parent, const char *name)
|
||
{
|
||
auto *found = parent.findChild<Widget *>(QString::fromLatin1(name));
|
||
if (!found)
|
||
throw std::runtime_error(name);
|
||
return *found;
|
||
}
|
||
}
|
||
|
||
class GradientCanvasTests : public QObject {
|
||
Q_OBJECT
|
||
private slots:
|
||
void aDragDrawsAPendingGradientAndItsLine();
|
||
void endsAreGrabbedWithinTenPoints();
|
||
void shiftKeepsTheLineToFortyFiveDegrees();
|
||
void escapeCancelsAndReturnApplies();
|
||
void theOverlayDrawsTheLineAsSwift();
|
||
void aFoldersPendingMaskClipsAtOnce();
|
||
void theBarSetsTheGradient();
|
||
void theSwatchShowsTheColoursOverChecks();
|
||
};
|
||
|
||
void GradientCanvasTests::aDragDrawsAPendingGradientAndItsLine()
|
||
{
|
||
Canvas shown;
|
||
gradient(shown);
|
||
const QColor ground = shownAt(shown, 200, 100);
|
||
shown.drag(QPointF(50, 150), QPointF(350, 150));
|
||
const std::optional<GradientEdit> &edit = shown.session.gradientEdit();
|
||
QVERIFY(edit.has_value());
|
||
QCOMPARE(edit.value().start, QPointF(50, 150));
|
||
QCOMPARE(edit.value().end, QPointF(350, 150));
|
||
QCOMPARE(shown.canvas->synchronizeDisplay(), false);
|
||
// Black at the start, fading; the line and ends above.
|
||
QCOMPARE(shownAt(shown, 40, 100), QColor(0, 0, 0));
|
||
const QColor mid = shownAt(shown, 200, 100);
|
||
QVERIFY2(std::abs(mid.red() - ground.red() / 2) <= 3, qPrintable(mid.name() + " " + ground.name()));
|
||
// Each end: its colour, a white ring, a black rim.
|
||
QCOMPARE(shownAt(shown, 50, 150), QColor(0, 0, 0));
|
||
QCOMPARE(shownAt(shown, 50, 145), QColor(255, 255, 255));
|
||
const QImage view = shown.canvas->grab().toImage();
|
||
QVERIFY(std::min({view.pixelColor(50, 143).red(), view.pixelColor(50, 144).red()}) < 110);
|
||
QCOMPARE(shownAt(shown, 350, 150), QColor(191, 191, 191));
|
||
// The white line over the black one lightens its row.
|
||
QVERIFY(shownAt(shown, 200, 150).red() > mid.red() + 40);
|
||
// A click without a line leaves nothing pending.
|
||
shown.session.cancelGradient();
|
||
shown.click(QPointF(100, 100));
|
||
QVERIFY(!shown.session.gradientEdit());
|
||
}
|
||
|
||
void GradientCanvasTests::endsAreGrabbedWithinTenPoints()
|
||
{
|
||
Canvas shown;
|
||
gradient(shown);
|
||
shown.drag(QPointF(50, 150), QPointF(350, 150));
|
||
// Nine points from the end grabs it; the start stays.
|
||
shown.drag(QPointF(359, 150), QPointF(300, 100));
|
||
QCOMPARE(shown.session.gradientEdit().value().start, QPointF(50, 150));
|
||
QCOMPARE(shown.session.gradientEdit().value().end, QPointF(300, 100));
|
||
shown.drag(QPointF(52, 157), QPointF(80, 200));
|
||
QCOMPARE(shown.session.gradientEdit().value().start, QPointF(80, 200));
|
||
QCOMPARE(shown.session.gradientEdit().value().end, QPointF(300, 100));
|
||
// Eleven points from either end starts a new line there.
|
||
shown.drag(QPointF(80, 211), QPointF(150, 250));
|
||
QCOMPARE(shown.session.gradientEdit().value().start, QPointF(80, 211));
|
||
shown.drag(QPointF(161, 250), QPointF(250, 250));
|
||
shown.drag(QPointF(311, 100), QPointF(390, 20));
|
||
QCOMPARE(shown.session.gradientEdit().value().start, QPointF(311, 100));
|
||
QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20));
|
||
// A release lets go: hovering moves nothing.
|
||
shown.hover(QPointF(200, 200));
|
||
QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20));
|
||
// Losing focus drops a drag under way.
|
||
shown.canvas->setFocus();
|
||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||
shown.press(QPointF(390, 20));
|
||
QWidget other(&shown.window);
|
||
other.setFocusPolicy(Qt::StrongFocus);
|
||
other.show();
|
||
other.setFocus();
|
||
QTRY_VERIFY(!shown.canvas->hasFocus());
|
||
shown.move(QPointF(200, 200));
|
||
QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20));
|
||
shown.release(QPointF(200, 200));
|
||
}
|
||
|
||
void GradientCanvasTests::shiftKeepsTheLineToFortyFiveDegrees()
|
||
{
|
||
Canvas shown;
|
||
gradient(shown);
|
||
shown.press(QPointF(100, 100));
|
||
shown.move(QPointF(200, 130), Qt::ShiftModifier);
|
||
const QPointF end = shown.session.gradientEdit().value().end;
|
||
QCOMPARE(end.y(), 100.0);
|
||
QCOMPARE(end.x(), 100 + std::hypot(100.0, 30.0));
|
||
shown.move(QPointF(180, 170), Qt::ShiftModifier);
|
||
const QPointF diagonal = shown.session.gradientEdit().value().end;
|
||
QVERIFY(std::abs((diagonal.x() - 100) - (diagonal.y() - 100)) < 1e-9);
|
||
shown.release(QPointF(180, 170), Qt::ShiftModifier);
|
||
// The start snaps around the end the same way.
|
||
shown.press(QPointF(100, 100));
|
||
shown.move(QPointF(100 + 3, 250), Qt::ShiftModifier);
|
||
const QPointF start = shown.session.gradientEdit().value().start, fixed = shown.session.gradientEdit().value().end;
|
||
QVERIFY(start.x() < fixed.x() && std::abs(std::abs(start.x() - fixed.x()) - std::abs(start.y() - fixed.y())) < 1e-9);
|
||
shown.release(QPointF(103, 250));
|
||
}
|
||
|
||
void GradientCanvasTests::escapeCancelsAndReturnApplies()
|
||
{
|
||
Canvas shown;
|
||
gradient(shown);
|
||
shown.canvas->setFocus();
|
||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||
shown.drag(QPointF(50, 150), QPointF(350, 150));
|
||
QTest::keyClick(shown.canvas, Qt::Key_Escape);
|
||
QVERIFY(!shown.session.gradientEdit());
|
||
const int count = shown.session.history.undoCount();
|
||
shown.drag(QPointF(50, 150), QPointF(350, 150));
|
||
QTest::keyClick(shown.canvas, Qt::Key_Return);
|
||
QTRY_COMPARE(shown.session.history.undoCount(), count + 1);
|
||
QVERIFY(!shown.session.gradientEdit());
|
||
shown.drag(QPointF(50, 150), QPointF(350, 150));
|
||
QTest::keyClick(shown.canvas, Qt::Key_Enter);
|
||
QTRY_COMPARE(shown.session.history.undoCount(), count + 2);
|
||
}
|
||
|
||
void GradientCanvasTests::theOverlayDrawsTheLineAsSwift()
|
||
{
|
||
Canvas shown;
|
||
QImage image = BrushRaster::context(60, 40, false);
|
||
image.fill(QColor(90, 90, 90));
|
||
shown.session.insert(ImportedImage(image, image, QStringLiteral("Block")));
|
||
shown.session.selectTool(NavigationTool::gradient);
|
||
const TransformOverlay overlay(shown.session);
|
||
const auto drawn = [&] {
|
||
QImage layer(400, 300, QImage::Format_ARGB32_Premultiplied);
|
||
layer.fill(Qt::transparent);
|
||
QPainter painter(&layer);
|
||
overlay.draw(painter, shown.canvas->palette());
|
||
return layer;
|
||
};
|
||
// A press alone has no line to draw.
|
||
shown.session.beginGradient(QPointF(100, 150));
|
||
QVERIFY(!overlay.gradientLine());
|
||
shown.session.moveGradient(std::nullopt, QPointF(300, 150));
|
||
// A three-point shade under the one-point white line.
|
||
QVERIFY(qAlpha(drawn().pixel(200, 148)) > 40);
|
||
QCOMPARE(qAlpha(drawn().pixel(200, 146)), 0);
|
||
// The radial's dashed rim, at the line's length.
|
||
GradientSettings radial = shown.session.gradientSettings();
|
||
radial.shape = GradientShape::radial;
|
||
shown.session.setGradientSettings(radial);
|
||
shown.session.moveGradient(std::nullopt, QPointF(160, 150));
|
||
const QImage rim = drawn();
|
||
int dashes = 0;
|
||
for (int step = 0; step < 160; ++step) {
|
||
const double angle = (100 + step) * std::numbers::pi / 180;
|
||
dashes += qAlpha(rim.pixel(int(100 + 60 * std::cos(angle)), int(150 + 60 * std::sin(angle)))) > 0;
|
||
}
|
||
QVERIFY2(dashes > 40 && dashes < 150, qPrintable(QString::number(dashes)));
|
||
// Switched to Move, not yet applied: the line, no handles.
|
||
shown.session.selectTool(NavigationTool::move);
|
||
QVERIFY(shown.session.gradientEdit() && overlay.geometry());
|
||
QCOMPARE(qAlpha(drawn().pixel(int(overlay.geometry().value().handles[0].x()), int(overlay.geometry().value().handles[0].y()))), 0);
|
||
QTRY_VERIFY(!shown.session.gradientEdit());
|
||
}
|
||
|
||
void GradientCanvasTests::aFoldersPendingMaskClipsAtOnce()
|
||
{
|
||
Canvas shown;
|
||
QImage image = BrushRaster::context(400, 300, false);
|
||
image.fill(QColor(200, 30, 30));
|
||
shown.session.insert(ImportedImage(image, image, QStringLiteral("Red")));
|
||
shown.session.groupSelectedLayers();
|
||
const QUuid folder = shown.session.activeLayerID().value();
|
||
QVERIFY(shown.session.document().value().layers[size_t(indexOf(shown.session.document().value().layers, folder))].isGroup);
|
||
shown.session.addLayerMask(true);
|
||
shown.session.selectLayerTarget(folder, true);
|
||
shown.session.selectTool(NavigationTool::gradient);
|
||
GradientSettings settings = shown.session.gradientSettings();
|
||
settings.style = GradientStyle::foregroundToBackground;
|
||
shown.session.setGradientSettings(settings);
|
||
shown.canvas->synchronizeDisplay();
|
||
QCOMPARE(shownAt(shown, 20, 100), QColor(200, 30, 30));
|
||
// Black to white across the folder's mask, still pending.
|
||
shown.drag(QPointF(0, 150), QPointF(400, 150));
|
||
QVERIFY(shown.session.gradientEdit().value().raster->isMask);
|
||
// Over gray checks, red minus green measures the red shown.
|
||
const auto red = [&](int x) {
|
||
const QColor seen = shownAt(shown, x, 100);
|
||
return seen.red() - seen.green();
|
||
};
|
||
const int mid = red(200);
|
||
QVERIFY2(red(20) < 20 && red(380) > 150 && std::abs(mid - 85) <= 4, qPrintable(QString::number(mid)));
|
||
// The step lands as it looked.
|
||
shown.session.commitGradient();
|
||
QTRY_VERIFY(!shown.session.gradientEdit());
|
||
shown.canvas->synchronizeDisplay();
|
||
QVERIFY(std::abs(red(200) - mid) <= 2);
|
||
}
|
||
|
||
void GradientCanvasTests::theBarSetsTheGradient()
|
||
{
|
||
EditorSession session;
|
||
session.createDocument(40, 20, true);
|
||
session.selectTool(NavigationTool::gradient);
|
||
GradientControls bar(session);
|
||
bar.show();
|
||
QCOMPARE(bar.title->text(), QString("Gradient"));
|
||
QStringList labels;
|
||
for (const QLabel *label : bar.findChildren<QLabel *>())
|
||
labels << label->text();
|
||
labels.sort();
|
||
QCOMPARE(labels, (QStringList{"%", "Gradient", "Mask", "Opacity"}));
|
||
auto &linear = find<QToolButton>(bar, "gradientLinear"), &radial = find<QToolButton>(bar, "gradientRadial");
|
||
QCOMPARE(radial.toolTip(), QString("Linear runs along the line; Radial spreads out from the start point"));
|
||
QVERIFY(linear.isChecked());
|
||
radial.click();
|
||
QCOMPARE(session.gradientSettings().shape, GradientShape::radial);
|
||
QVERIFY(radial.isChecked() && !linear.isChecked());
|
||
linear.click();
|
||
QCOMPARE(session.gradientSettings().shape, GradientShape::linear);
|
||
QVERIFY(linear.isChecked() && !radial.isChecked());
|
||
auto &style = find<QComboBox>(bar, "gradientStyle");
|
||
QCOMPARE(style.count(), 2);
|
||
QCOMPARE(style.itemText(0), QString("Foreground to Background"));
|
||
QCOMPARE(style.itemText(1), QString("Foreground to Transparent"));
|
||
QCOMPARE(style.currentIndex(), 1);
|
||
style.setCurrentIndex(0);
|
||
emit style.activated(0);
|
||
QCOMPARE(session.gradientSettings().style, GradientStyle::foregroundToBackground);
|
||
style.setCurrentIndex(1);
|
||
emit style.activated(1);
|
||
QCOMPARE(session.gradientSettings().style, GradientStyle::foregroundToTransparent);
|
||
auto &reverse = find<QCheckBox>(bar, "gradientReverse");
|
||
reverse.click();
|
||
QVERIFY(session.gradientSettings().reversed && reverse.isChecked());
|
||
reverse.click();
|
||
QVERIFY(!session.gradientSettings().reversed);
|
||
// The slider runs from 1% to 100% in thousandths.
|
||
auto &slider = find<QSlider>(bar, "gradientOpacitySlider");
|
||
QCOMPARE(slider.width(), 100);
|
||
slider.setValue(0);
|
||
QCOMPARE(session.gradientSettings().opacity, 0.01);
|
||
slider.setValue(5000);
|
||
QCOMPARE(session.gradientSettings().opacity, 1.0);
|
||
slider.setValue(333);
|
||
QCOMPARE(session.gradientSettings().opacity, 0.333);
|
||
auto &field = find<SelectionAmountField>(bar, "gradientOpacity");
|
||
QCOMPARE(field.text(), QString("33"));
|
||
QCOMPARE(field.width(), 42);
|
||
QCOMPARE(field.toolTip(), QString("Press 1–9 for 10–90%, 0 for 100%"));
|
||
field.setFocus();
|
||
field.selectAll();
|
||
QTest::keyClicks(&field, "70");
|
||
QTest::keyClick(&field, Qt::Key_Return);
|
||
QCOMPARE(session.gradientSettings().opacity, 0.7);
|
||
// The session's changes show without writing back.
|
||
session.setGradientSettings(GradientSettings{GradientShape::radial, GradientStyle::foregroundToBackground, true, 0.12345});
|
||
QVERIFY(radial.isChecked() && style.currentIndex() == 0 && reverse.isChecked());
|
||
QCOMPARE(slider.value(), 123);
|
||
QCOMPARE(field.text(), QString("12"));
|
||
QCOMPARE(session.gradientSettings().opacity, 0.12345);
|
||
session.setGradientSettings(GradientSettings{GradientShape::linear, GradientStyle::foregroundToTransparent, false, 0.5});
|
||
QVERIFY(linear.isChecked() && style.currentIndex() == 1 && !reverse.isChecked());
|
||
QCOMPARE(slider.value(), 500);
|
||
// Apply and Cancel while pending, at the far end.
|
||
auto &apply = find<QPushButton>(bar, "gradientApply"), &cancel = find<QPushButton>(bar, "gradientCancel");
|
||
QVERIFY(!apply.isVisible() && !cancel.isVisible());
|
||
session.beginGradient(QPointF(1, 1));
|
||
session.moveGradient(std::nullopt, QPointF(30, 1));
|
||
QVERIFY(apply.isVisible() && cancel.isVisible());
|
||
bar.resize(bar.sizeHint().width() + 300, bar.height());
|
||
QTRY_VERIFY(cancel.x() - field.geometry().right() > 250 && apply.x() > cancel.x());
|
||
const int count = session.history.undoCount();
|
||
cancel.click();
|
||
QVERIFY(!session.gradientEdit());
|
||
QCOMPARE(session.history.undoCount(), count);
|
||
session.beginGradient(QPointF(1, 1));
|
||
session.moveGradient(std::nullopt, QPointF(30, 1));
|
||
apply.click();
|
||
QTRY_COMPARE(session.history.undoCount(), count + 1);
|
||
// The edit ends from the event loop, after its step.
|
||
QTRY_VERIFY(!session.gradientEdit());
|
||
// The note, in secondary text, on a mask.
|
||
auto ¬e = find<QLabel>(bar, "gradientMaskNote");
|
||
QVERIFY(!note.isVisible());
|
||
QCOMPARE(note.foregroundRole(), QPalette::PlaceholderText);
|
||
session.addLayerMask(true);
|
||
QVERIFY(note.isVisible());
|
||
session.setIsProjectBusy(true);
|
||
QTRY_VERIFY(!bar.isEnabled());
|
||
session.setIsProjectBusy(false);
|
||
QVERIFY(bar.isEnabled());
|
||
EditorSession empty;
|
||
const GradientControls idle(empty);
|
||
QVERIFY(!idle.isEnabled());
|
||
}
|
||
|
||
void GradientCanvasTests::theSwatchShowsTheColoursOverChecks()
|
||
{
|
||
EditorSession session;
|
||
session.createDocument(40, 20, true);
|
||
session.selectTool(NavigationTool::gradient);
|
||
session.setForegroundColor(PaletteColor{1, 0, 0});
|
||
GradientControls bar(session);
|
||
QWidget &swatch = find<QWidget>(bar, "gradientSwatch");
|
||
PaintSpy spy(swatch);
|
||
bar.show();
|
||
QTRY_VERIFY(spy.count > 0);
|
||
QCOMPARE(swatch.size(), QSize(56, 18));
|
||
const auto drawn = [&] {
|
||
QImage image(56, 18, QImage::Format_ARGB32_Premultiplied);
|
||
image.fill(Qt::transparent);
|
||
swatch.render(&image, QPoint(), QRegion(), QWidget::DrawChildren);
|
||
return image;
|
||
};
|
||
const QImage shown = drawn();
|
||
// Red at the start, fading to clear over the checks.
|
||
QVERIFY(shown.pixelColor(1, 9).red() > 240 && shown.pixelColor(1, 9).green() < 12);
|
||
QVERIFY(std::abs(shown.pixelColor(28, 10).green() - 130) <= 4);
|
||
QVERIFY(std::abs(shown.pixelColor(50, 6).green() - 230) <= 4);
|
||
for (const int y : {2, 6, 10, 14, 16})
|
||
QCOMPARE(shown.pixelColor(50, y).green() < 210, y != 6 && y != 14);
|
||
// A half-black rim, its corners rounded and smooth.
|
||
QVERIFY(shown.pixelColor(28, 0).green() < shown.pixelColor(28, 10).green() - 40);
|
||
QVERIFY(qAlpha(shown.pixel(0, 0)) < 40);
|
||
QVERIFY(qAlpha(shown.pixel(1, 0)) > 40 && qAlpha(shown.pixel(1, 0)) < 240);
|
||
// Every change repaints it.
|
||
QApplication::processEvents();
|
||
spy.count = 0;
|
||
session.setForegroundColor(PaletteColor{0, 0, 1});
|
||
QTRY_VERIFY(spy.count > 0);
|
||
QVERIFY(drawn().pixelColor(1, 9).blue() > 240);
|
||
}
|
||
|
||
QTEST_MAIN(GradientCanvasTests)
|
||
#include "GradientCanvasTests.moc"
|