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>
280 lines
13 KiB
C++
280 lines
13 KiB
C++
#include "BrushFixtures.h"
|
||
#include "CanvasFixtures.h"
|
||
#include "SelectionCanvasFixtures.h"
|
||
#include "SelectionFixtures.h"
|
||
|
||
// Painting on the canvas: nothing away from the brush moves.
|
||
namespace {
|
||
struct Shift {
|
||
int start;
|
||
int finish;
|
||
bool committed;
|
||
};
|
||
|
||
// Swift's canvasShift: a big photo scaled down, painted mid.
|
||
Shift canvasShift(bool paintingMask, double layerScale = 0.25)
|
||
{
|
||
Shown shown(QSize(800, 600), QSize(800, 600));
|
||
EditorSession &session = shown.session;
|
||
const QImage photo = noise(2400, 1800, 3);
|
||
session.insert(ImportedImage(photo, photo, "Photo"));
|
||
const QUuid id = session.activeLayerID().value();
|
||
LayerTransform transform{.origin = {100, 75}, .size = {2400 * layerScale, 1800 * layerScale}};
|
||
transform.sampling = LayerSampling::high;
|
||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = transform; });
|
||
session.selectLayer(id);
|
||
if (paintingMask) {
|
||
session.addLayerMask();
|
||
if (!session.isMaskSelected())
|
||
throw std::runtime_error("the mask was not targeted");
|
||
}
|
||
shown.settle();
|
||
session.selectTool(NavigationTool::brush);
|
||
BrushSettings settings = session.brushSettings();
|
||
settings.diameter = 30;
|
||
session.setBrushSettings(settings);
|
||
const auto snapshot = [&] {
|
||
shown.canvas->synchronizeDisplay();
|
||
return shown.canvas->grab().toImage().convertToFormat(QImage::Format_RGBA8888);
|
||
};
|
||
// The brushed area in view pixels, grown generously.
|
||
const QPointF brush = transform.center();
|
||
const QSizeF size = session.document().value().size();
|
||
const QPointF topLeft = session.viewport.viewPoint(QPointF(brush.x() - 60, brush.y() - 40), size);
|
||
const QPointF bottomRight = session.viewport.viewPoint(QPointF(brush.x() + 80, brush.y() + 40), size);
|
||
const auto largestDifference = [&](const QImage &a, const QImage &b) {
|
||
const double perPoint = double(a.width()) / shown.canvas->width();
|
||
const QRectF box(topLeft.x() * perPoint, topLeft.y() * perPoint, (bottomRight.x() - topLeft.x()) * perPoint, (bottomRight.y() - topLeft.y()) * perPoint);
|
||
int largest = 0;
|
||
for (int y = 0; y < a.height(); ++y) {
|
||
const uchar *rowA = a.constScanLine(y), *rowB = b.constScanLine(y);
|
||
for (int x = 0; x < a.width(); ++x) {
|
||
if (box.contains(QPointF(x, y)))
|
||
continue;
|
||
for (int c = 0; c < 4; ++c)
|
||
largest = std::max(largest, std::abs(int(rowA[x * 4 + c]) - int(rowB[x * 4 + c])));
|
||
}
|
||
}
|
||
return largest;
|
||
};
|
||
const QImage before = snapshot();
|
||
session.beginBrush(brush);
|
||
session.continueBrush(QPointF(brush.x() + 20, brush.y()));
|
||
const QImage during = snapshot();
|
||
session.finishBrushImmediately();
|
||
const QImage after = snapshot();
|
||
const ImageLayer layer = session.activeLayer().value();
|
||
const bool committed = paintingMask ? layer.mask.value().asset.raster != nullptr : layer.asset.value().raster != nullptr;
|
||
return {largestDifference(before, during), largestDifference(during, after), committed};
|
||
}
|
||
}
|
||
|
||
class BrushCanvasTests : public QObject {
|
||
Q_OBJECT
|
||
private slots:
|
||
void paintingAScaledDownLayerDoesNotShiftItsPixels();
|
||
void paintingAScaledDownLayersMaskDoesNotShiftItsPixels();
|
||
void theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes();
|
||
void aPlacedMaskStrokeShowsTheLayerThroughItsPreview();
|
||
void aStrokeStepRepaintsWhereItWentAlone();
|
||
void paintingAFolderMaskClipsItsLayersLive();
|
||
void aStripedFolderMaskKeepsItsShadeWhilePainted();
|
||
};
|
||
|
||
void BrushCanvasTests::paintingAScaledDownLayerDoesNotShiftItsPixels()
|
||
{
|
||
const Shift shift = canvasShift(false);
|
||
QVERIFY2(shift.committed, "the stroke was committed");
|
||
QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint changed pixels away from the brush by %1").arg(shift.start)));
|
||
QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the stroke changed pixels away from the brush by %1").arg(shift.finish)));
|
||
}
|
||
|
||
void BrushCanvasTests::paintingAScaledDownLayersMaskDoesNotShiftItsPixels()
|
||
{
|
||
const Shift shift = canvasShift(true);
|
||
QVERIFY2(shift.committed, "the mask stroke was committed");
|
||
QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint the mask changed pixels away from the brush by %1").arg(shift.start)));
|
||
QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the mask stroke changed pixels away from the brush by %1").arg(shift.finish)));
|
||
}
|
||
|
||
void BrushCanvasTests::theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes()
|
||
{
|
||
Shown shown(QSize(400, 300), QSize(400, 300));
|
||
EditorSession &session = shown.session;
|
||
QImage blue = BrushRaster::context(400, 300, false);
|
||
blue.fill(Qt::blue);
|
||
session.insert(ImportedImage(blue, blue, "Blue"));
|
||
shown.settle();
|
||
session.zoom(1);
|
||
session.selectTool(NavigationTool::brush);
|
||
session.setBrushSettings(brush(20, 1, 1, 0, 0));
|
||
shown.canvas->synchronizeDisplay();
|
||
// Each step of a stroke is a new display state.
|
||
session.beginBrush(QPointF(50, 50));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(50, 50), QColor(Qt::red));
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::blue));
|
||
session.continueBrush(QPointF(200, 50));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QVERIFY(!shown.canvas->synchronizeDisplay());
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red));
|
||
session.finishBrush();
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red));
|
||
// Delete clears a selection; without one it deletes the layer.
|
||
session.selectTool(NavigationTool::marquee);
|
||
session.applySelection(rectPath(QRectF(0, 0, 100, 300)), SelectionMode::replace, "Select");
|
||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||
QTest::keyClick(shown.canvas, Qt::Key_Delete);
|
||
QTRY_COMPARE(session.history.undoName(), QString("Clear"));
|
||
session.deselect();
|
||
const int layers = int(session.document().value().layers.size());
|
||
QTest::keyClick(shown.canvas, Qt::Key_Backspace);
|
||
QCOMPARE(int(session.document().value().layers.size()), layers - 1);
|
||
// With Ctrl or Alt the key is not Delete's.
|
||
session.undo();
|
||
QTest::keyClick(shown.canvas, Qt::Key_Delete, Qt::ControlModifier);
|
||
QCOMPARE(int(session.document().value().layers.size()), layers);
|
||
}
|
||
|
||
void BrushCanvasTests::aPlacedMaskStrokeShowsTheLayerThroughItsPreview()
|
||
{
|
||
Shown shown(QSize(200, 200), QSize(200, 200));
|
||
EditorSession &session = shown.session;
|
||
QImage blue = BrushRaster::context(64, 64, false);
|
||
blue.fill(Qt::blue);
|
||
session.insert(ImportedImage(blue, blue, "Blue"), QPointF(96, 56));
|
||
const QUuid id = session.activeLayerID().value();
|
||
// A black 16 px mask at 32 from (80,40): hidden.
|
||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||
setMask(snapshot, id, LayerMask::assetFrom(BrushRaster::context(16, 16, true)));
|
||
record(snapshot, id).maskPlacement = LayerTransform{.origin = {80, 40}, .size = {32, 32}};
|
||
record(snapshot, id).maskLinked = false;
|
||
});
|
||
session.selectLayerTarget(id, true);
|
||
shown.settle();
|
||
session.zoom(1);
|
||
session.selectTool(NavigationTool::brush);
|
||
session.setBrushSettings(brush(8, 1, 1, 1, 1));
|
||
session.setMaskPaintWhite(true);
|
||
shown.canvas->synchronizeDisplay();
|
||
QVERIFY(shown.canvas->grab().toImage().pixelColor(88, 48) != QColor(Qt::blue));
|
||
session.beginBrush(QPointF(88, 48));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue));
|
||
QVERIFY(shown.canvas->grab().toImage().pixelColor(70, 70) != QColor(Qt::blue));
|
||
session.finishBrush();
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue));
|
||
}
|
||
|
||
void BrushCanvasTests::aStrokeStepRepaintsWhereItWentAlone()
|
||
{
|
||
Shown shown(QSize(400, 300), QSize(400, 300));
|
||
EditorSession &session = shown.session;
|
||
session.addBlankLayer();
|
||
shown.settle();
|
||
session.zoom(1);
|
||
session.selectTool(NavigationTool::brush);
|
||
session.setBrushSettings(brush(20, 1, 1, 0, 0));
|
||
shown.canvas->synchronizeDisplay();
|
||
QTest::qWait(50);
|
||
PaintSpy spy(*shown.canvas);
|
||
// The first tile alone, two points out: not everything.
|
||
session.beginBrush(QPointF(50, 50));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QTRY_VERIFY(spy.count > 0);
|
||
QCOMPARE(spy.painted, QRect(0, 0, 258, 258));
|
||
spy.painted = QRect();
|
||
spy.count = 0;
|
||
// Into the next tile: both, still not everything.
|
||
session.continueBrush(QPointF(300, 50));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QTRY_VERIFY(spy.count > 0);
|
||
QCOMPARE(spy.painted, QRect(0, 0, 400, 258));
|
||
session.cancelBrush();
|
||
}
|
||
|
||
void BrushCanvasTests::paintingAFolderMaskClipsItsLayersLive()
|
||
{
|
||
Shown shown(QSize(300, 300), QSize(300, 300));
|
||
EditorSession &session = shown.session;
|
||
QImage red = BrushRaster::context(100, 100, false);
|
||
red.fill(Qt::red);
|
||
session.insert(ImportedImage(red, red, "Red"), QPointF(100, 100));
|
||
session.groupSelectedLayers();
|
||
session.addLayerMask(true);
|
||
QVERIFY(session.isMaskSelected() && session.activeLayer().value().isGroup);
|
||
shown.settle();
|
||
session.selectTool(NavigationTool::brush);
|
||
session.setBrushSettings(brush(20, 1, 0, 0, 0));
|
||
session.setMaskPaintWhite(false);
|
||
for (double zoom : {1.0, 3.0}) {
|
||
session.zoom(zoom);
|
||
shown.canvas->synchronizeDisplay();
|
||
// Both points on screen at 3x: the view spans 100–200.
|
||
const QSizeF size = session.document().value().size();
|
||
const QPoint centre = session.viewport.viewPoint(QPointF(125, 125), size).toPoint();
|
||
const QPoint edge = session.viewport.viewPoint(QPointF(105, 105), size).toPoint();
|
||
QVERIFY(QRect(QPoint(0, 0), shown.canvas->size()).contains(edge));
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(centre), QColor(Qt::red));
|
||
// Black on the mask hides the red at once.
|
||
session.beginBrush(QPointF(125, 125));
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red));
|
||
QCOMPARE(shown.canvas->grab().toImage().pixelColor(edge), QColor(Qt::red));
|
||
session.finishBrush();
|
||
shown.canvas->synchronizeDisplay();
|
||
QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red));
|
||
session.undo();
|
||
QVERIFY(session.isMaskSelected());
|
||
}
|
||
}
|
||
|
||
void BrushCanvasTests::aStripedFolderMaskKeepsItsShadeWhilePainted()
|
||
{
|
||
Shown shown(QSize(1200, 1200), QSize(300, 300));
|
||
EditorSession &session = shown.session;
|
||
QImage red = BrushRaster::context(1200, 1200, false);
|
||
red.fill(Qt::red);
|
||
session.insert(ImportedImage(red, red, "Red"));
|
||
session.groupSelectedLayers();
|
||
const QUuid folder = session.activeLayerID().value();
|
||
// One-pixel stripes: halved at a quarter, they read gray.
|
||
QImage stripes = BrushRaster::context(1200, 1200, true);
|
||
for (int y = 0; y < 1200; ++y) {
|
||
for (int x = 0; x < 1200; x += 2)
|
||
stripes.scanLine(y)[x] = 255;
|
||
}
|
||
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(stripes)); });
|
||
session.selectLayerTarget(folder, true);
|
||
QVERIFY(session.isMaskSelected());
|
||
shown.settle();
|
||
session.zoom(0.25);
|
||
session.selectTool(NavigationTool::brush);
|
||
session.setBrushSettings(brush(40, 1, 1, 1, 1));
|
||
session.setMaskPaintWhite(true);
|
||
shown.canvas->synchronizeDisplay();
|
||
const QPoint far = session.viewport.viewPoint(QPointF(1000, 1000), session.document().value().size()).toPoint();
|
||
const auto shade = [&] { return shown.canvas->grab().toImage().pixelColor(far); };
|
||
const auto near = [](const QColor &a, const QColor &b) {
|
||
return std::abs(a.red() - b.red()) <= 3 && std::abs(a.green() - b.green()) <= 3 && std::abs(a.blue() - b.blue()) <= 3;
|
||
};
|
||
const QColor before = shade();
|
||
QVERIFY(before != QColor(Qt::red));
|
||
// Far from each dab, the untouched shade stays put.
|
||
for (const QPointF dab : {QPointF(100, 100), QPointF(100, 300)}) {
|
||
session.beginBrush(dab);
|
||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||
const QColor during = shade();
|
||
QVERIFY2(near(before, during), qPrintable(QStringLiteral("%1 then %2").arg(before.name(), during.name())));
|
||
session.finishBrush();
|
||
shown.canvas->synchronizeDisplay();
|
||
QVERIFY(near(before, shade()));
|
||
// The second dab reads the tiles the first one painted.
|
||
QVERIFY(layerWith(session, folder).mask.value().asset.raster);
|
||
}
|
||
}
|
||
|
||
QTEST_MAIN(BrushCanvasTests)
|
||
#include "BrushCanvasTests.moc"
|