295 lines
13 KiB
C++
295 lines
13 KiB
C++
#include "BudgetFixtures.h"
|
|
#include "BrushFixtures.h"
|
|
#include "IO/ImageExporter.h"
|
|
#include "IO/ProjectStore.h"
|
|
#include "SelectionFixtures.h"
|
|
#include "Rendering/RasterSnapshot.h"
|
|
#include "SessionRecord.h"
|
|
#include <QTemporaryDir>
|
|
|
|
// The session's pixel edits: strokes, erase, clears, fills.
|
|
namespace {
|
|
// Swift's makeSession: a blank layer, the Brush, hard red 20.
|
|
std::unique_ptr<EditorSession> makeSession(int width = 600, int height = 80)
|
|
{
|
|
auto session = std::make_unique<EditorSession>();
|
|
session->createDocument(width, height, true);
|
|
session->addBlankLayer();
|
|
session->selectTool(NavigationTool::brush);
|
|
session->setBrushSettings(brush(20, 1, 1, 0, 0));
|
|
return session;
|
|
}
|
|
|
|
QImage render(const EditorSession &session)
|
|
{
|
|
return ImageExporter::render(session.projectSnapshot().value()).image;
|
|
}
|
|
|
|
QImage live(const EditorSession &session)
|
|
{
|
|
return preview(*session.brushStroke(), session.document().value().size());
|
|
}
|
|
|
|
void select(EditorSession &session, const QRectF &rect)
|
|
{
|
|
session.applySelection(rectPath(rect), SelectionMode::replace, "Select");
|
|
}
|
|
}
|
|
|
|
class PixelEditTests : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded();
|
|
void eraseClearsAndDeleteFillsAndClears();
|
|
void brushCallsAnnounceAndRefuseInSilence();
|
|
void aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer();
|
|
void aMaskSwitchedOffMeanwhileStaysOff();
|
|
};
|
|
|
|
void PixelEditTests::aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded()
|
|
{
|
|
const auto session = makeSession();
|
|
const QUuid id = session->activeLayerID().value();
|
|
QVERIFY(!session->shiftLineStart().has_value());
|
|
session->addLayerMask(true);
|
|
session->selectLayerTarget(id, false);
|
|
session->beginBrush(QPointF(20, 40));
|
|
// While the stroke lasts nothing else moves.
|
|
QVERIFY(!session->canEditLayers() && !session->canUseHistory() && !session->canStartProjectOperation());
|
|
QVERIFY(!session->canInvert() && !session->canAdjustColors() && !session->canContentAwareFill());
|
|
const int count = session->history.undoCount();
|
|
bool inverted = false;
|
|
session->invertPixels([&] { inverted = true; });
|
|
QTRY_VERIFY(inverted);
|
|
QVERIFY(session->brushStroke() && session->history.undoCount() == count);
|
|
session->selectTool(NavigationTool::move);
|
|
QCOMPARE(session->tool(), NavigationTool::brush);
|
|
session->selectLayer(session->document().value().layers.front().id);
|
|
QCOMPARE(session->activeLayerID(), std::optional(id));
|
|
session->selectLayerTarget(id, true);
|
|
QVERIFY(!session->isMaskSelected());
|
|
session->continueBrush(QPointF(100, 40));
|
|
QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40)));
|
|
// Busy, the mouse-up waits; free, it commits.
|
|
session->setIsProjectBusy(true);
|
|
QVERIFY(!session->finishBrushImmediately());
|
|
session->setIsProjectBusy(false);
|
|
QVERIFY(session->finishBrushImmediately());
|
|
QVERIFY(session->finishBrushImmediately());
|
|
QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40)));
|
|
session->selectLayer(session->document().value().layers.front().id);
|
|
QVERIFY(!session->shiftLineStart().has_value());
|
|
session->selectLayer(id);
|
|
session->selectLayerTarget(id, true);
|
|
QVERIFY(session->isMaskSelected() && !session->shiftLineStart().has_value());
|
|
// A Shift-click's line: the old end to the new point.
|
|
session->selectLayerTarget(id, false);
|
|
session->beginBrush(session->shiftLineStart().value());
|
|
session->continueBrush(QPointF(300, 40));
|
|
session->finishBrush();
|
|
QCOMPARE(alpha(render(*session), 200, 40), 255);
|
|
// Two selected layers paint nothing; an import ends a stroke.
|
|
const QUuid other = session->document().value().layers.front().id;
|
|
session->selectLayers({id, other}, id);
|
|
QVERIFY(!session->canPaint());
|
|
session->selectLayer(id);
|
|
QTemporaryDir folder;
|
|
const QString path = folder.filePath(QStringLiteral("dot.png"));
|
|
QImage dot(2, 2, QImage::Format_RGBA8888_Premultiplied);
|
|
dot.fill(Qt::green);
|
|
QVERIFY(dot.save(path));
|
|
session->beginBrush(QPointF(400, 40));
|
|
bool imported = false;
|
|
session->importImages({QUrl::fromLocalFile(path)}, std::nullopt, [&] { imported = true; });
|
|
QVERIFY(!session->brushStroke());
|
|
QCOMPARE(session->history.undoName(), QString("Brush Stroke"));
|
|
QTRY_VERIFY(imported);
|
|
QCOMPARE(session->activeLayer().value().name, QString("dot"));
|
|
}
|
|
|
|
void PixelEditTests::eraseClearsAndDeleteFillsAndClears()
|
|
{
|
|
const auto session = makeSession(20, 10);
|
|
QImage red = BrushRaster::context(20, 10, false);
|
|
red.fill(Qt::red);
|
|
session->insert(ImportedImage(red, red, "Red"));
|
|
session->selectTool(NavigationTool::brush);
|
|
// Pixels to invert or fill, refused while a stroke lasts.
|
|
QVERIFY(session->canInvert() && session->canAdjustColors());
|
|
session->beginBrush(QPointF(15, 5));
|
|
QVERIFY(!session->canInvert() && !session->canAdjustColors());
|
|
session->cancelBrush();
|
|
QVERIFY(session->canInvert() && session->canAdjustColors());
|
|
// Erase takes the pixels out; the mask keeps painting.
|
|
session->setBrushMode(BrushToolMode::erase);
|
|
session->setBrushSettings(brush(4, 1, 0, 0, 1));
|
|
session->beginBrush(QPointF(5, 5));
|
|
QCOMPARE(alpha(live(*session), 5, 5), 0);
|
|
session->finishBrush();
|
|
QCOMPARE(session->history.undoName(), QString("Erase"));
|
|
QCOMPARE(alpha(render(*session), 5, 5), 0);
|
|
QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255}));
|
|
session->undo();
|
|
// Delete with a selection clears; without, it deletes.
|
|
session->selectTool(NavigationTool::marquee);
|
|
select(*session, QRectF(0, 0, 10, 10));
|
|
bool done = false;
|
|
session->clearSelectedPixels([&] { done = true; });
|
|
QTRY_VERIFY(done);
|
|
QCOMPARE(session->history.undoName(), QString("Clear"));
|
|
QCOMPARE(alpha(render(*session), 5, 5), 0);
|
|
QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255}));
|
|
session->undo();
|
|
done = false;
|
|
session->fillSelection(EditorSession::FillSource::background, [&] { done = true; });
|
|
QTRY_VERIFY(done);
|
|
QCOMPARE(session->history.undoName(), QString("Fill"));
|
|
QCOMPARE(pixel(render(*session), 5, 5), (std::vector<int>{255, 255, 255, 255}));
|
|
session->undo();
|
|
session->deleteKeyPressed();
|
|
QTRY_COMPARE(session->history.undoName(), QString("Clear"));
|
|
session->deselect();
|
|
const int layers = int(session->document().value().layers.size());
|
|
session->deleteKeyPressed();
|
|
QCOMPARE(int(session->document().value().layers.size()), layers - 1);
|
|
// Back past the delete, deselect and clear: red again.
|
|
session->undo();
|
|
session->undo();
|
|
session->undo();
|
|
QCOMPARE(pixel(render(*session), 5, 5), (std::vector<int>{255, 0, 0, 255}));
|
|
// A mask target fills with the background: black's is white.
|
|
session->selectLayer(session->document().value().layers.back().id);
|
|
session->addLayerMask(true);
|
|
select(*session, QRectF(0, 0, 10, 10));
|
|
session->setMaskPaintWhite(false);
|
|
done = false;
|
|
session->clearSelectedPixels([&] { done = true; });
|
|
QTRY_VERIFY(done);
|
|
QCOMPARE(session->history.undoName(), QString("Fill Mask"));
|
|
QCOMPARE(alpha(render(*session), 5, 5), 255);
|
|
session->setMaskPaintWhite(true);
|
|
done = false;
|
|
session->clearSelectedPixels([&] { done = true; });
|
|
QTRY_VERIFY(done);
|
|
QCOMPARE(alpha(render(*session), 5, 5), 0);
|
|
}
|
|
|
|
void PixelEditTests::brushCallsAnnounceAndRefuseInSilence()
|
|
{
|
|
const auto session = makeSession();
|
|
QSignalSpy changes(session.get(), &EditorSession::changed);
|
|
const auto announced = [&](const std::function<void()> &change) {
|
|
const qsizetype before = changes.count();
|
|
change();
|
|
return changes.count() > before;
|
|
};
|
|
QVERIFY(!announced([&] { session->continueBrush(QPointF(1, 1)); }));
|
|
QVERIFY(announced([&] { session->changeBrushSize(true); }));
|
|
QVERIFY(announced([&] { session->setBrushMode(BrushToolMode::erase); }));
|
|
QVERIFY(announced([&] { session->setMaskPaintWhite(true); }));
|
|
QVERIFY(announced([&] { session->typeOpacityDigit(5, 1); }));
|
|
QVERIFY(announced([&] { session->beginBrush(QPointF(10, 10)); }));
|
|
QVERIFY(!announced([&] { session->selectTool(NavigationTool::move); }));
|
|
QVERIFY(!announced([&] { session->typeOpacityDigit(5, 2); }));
|
|
QVERIFY(announced([&] { session->continueBrush(QPointF(20, 10)); }));
|
|
QVERIFY(announced([&] { session->cancelBrush(); }));
|
|
// The last signal of a change sees what it leaves.
|
|
QStringList seen;
|
|
connect(session.get(), &EditorSession::changed, session.get(), [&] { seen = described(*session); });
|
|
session->beginBrush(QPointF(10, 10));
|
|
QCOMPARE(seen, described(*session));
|
|
session->continueBrush(QPointF(50, 10));
|
|
QCOMPARE(seen, described(*session));
|
|
session->finishBrush();
|
|
QCOMPARE(seen, described(*session));
|
|
session->changeBrushHardness(false);
|
|
QCOMPARE(seen, described(*session));
|
|
}
|
|
|
|
void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer()
|
|
{
|
|
const auto session = makeSession(20, 10);
|
|
QImage red = BrushRaster::context(20, 10, false);
|
|
red.fill(Qt::red);
|
|
session->insert(ImportedImage(red, red, "Red"));
|
|
const QUuid id = session->activeLayerID().value();
|
|
QImage white = BrushRaster::context(20, 10, true);
|
|
white.fill(Qt::white);
|
|
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); });
|
|
session->selectLayerTarget(id, false);
|
|
select(*session, QRectF(0, 0, 10, 10));
|
|
// The mask replaced while the clear flies: nothing lands.
|
|
bool done = false;
|
|
session->clearSelectedPixels([&] { done = true; });
|
|
QVERIFY(session->isProjectBusy());
|
|
QImage black = BrushRaster::context(20, 10, true);
|
|
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(black)); });
|
|
QTRY_VERIFY(done);
|
|
QVERIFY(!session->isProjectBusy());
|
|
QCOMPARE(session->history.undoCount(), 0);
|
|
QCOMPARE(int(layerWith(*session, id).mask.value().asset.image().constScanLine(5)[5]), 0);
|
|
QCOMPARE(pixel(layerWith(*session, id).asset.value().image(), 5, 5), (std::vector<int>{255, 0, 0, 255}));
|
|
// The other layers leave a stroke 4 million pixels.
|
|
const auto big = std::make_unique<EditorSession>();
|
|
big->createDocument(4000, 4000, true);
|
|
const QUuid first = big->activeLayerID().value();
|
|
QSet<QUuid> claims;
|
|
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 4'000'000)) {
|
|
big->insert(layer);
|
|
claims.insert(big->activeLayerID().value());
|
|
}
|
|
big->addBlankLayer();
|
|
big->selectTool(NavigationTool::brush);
|
|
QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(4'000'000));
|
|
// A 2000 px tip needs more tiles than that: refused.
|
|
big->setBrushSettings(brush(2000, 1, 1, 0, 0));
|
|
big->beginBrush(QPointF(2000, 2000));
|
|
QVERIFY(!big->brushStroke() && big->brushError().has_value());
|
|
big->setBrushError(std::nullopt);
|
|
big->setBrushSettings(brush(20, 1, 1, 0, 0));
|
|
big->beginBrush(QPointF(2000, 2000));
|
|
QVERIFY(big->brushStroke());
|
|
big->cancelBrush();
|
|
// A masked layer's budget counts the other masks too.
|
|
const QUuid last = big->activeLayerID().value();
|
|
rewrite(*big, [&](ProjectSnapshot &snapshot) {
|
|
for (ProjectLayerRecord &record : snapshot.manifest.layers)
|
|
setMask(snapshot, record.id, claims.contains(record.id) ? claimed(snapshot.images.at(record.id).size().width(), snapshot.images.at(record.id).size().height())
|
|
: claimed(4000, 4000));
|
|
});
|
|
QVERIFY(layerWith(*big, first).mask);
|
|
big->selectLayerTarget(last, false);
|
|
// The claims' masks and a 16-million one: below zero.
|
|
QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(-12'000'000));
|
|
}
|
|
|
|
void PixelEditTests::aMaskSwitchedOffMeanwhileStaysOff()
|
|
{
|
|
const auto session = makeSession(20, 10);
|
|
QImage red = BrushRaster::context(20, 10, false);
|
|
red.fill(Qt::red);
|
|
session->insert(ImportedImage(red, red, "Red"));
|
|
const QUuid id = session->activeLayerID().value();
|
|
QImage white = BrushRaster::context(20, 10, true);
|
|
white.fill(Qt::white);
|
|
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); });
|
|
session->selectLayerTarget(id, false);
|
|
select(*session, QRectF(0, 0, 10, 10));
|
|
bool done = false;
|
|
session->clearSelectedPixels([&] { done = true; });
|
|
QVERIFY(session->isProjectBusy());
|
|
// Only the switch changes meanwhile: same pixels, same place.
|
|
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = false; });
|
|
QTRY_VERIFY(done);
|
|
QVERIFY(!session->isProjectBusy());
|
|
QCOMPARE(session->history.undoCount(), 1);
|
|
QCOMPARE(session->history.undoName(), QString("Clear"));
|
|
QVERIFY(!layerWith(*session, id).mask.value().isEnabled);
|
|
// The commit crops the cleared half away: read the document.
|
|
QCOMPARE(pixel(render(*session), 5, 5)[3], 0);
|
|
QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255}));
|
|
}
|
|
|
|
QTEST_GUILESS_MAIN(PixelEditTests)
|
|
#include "PixelEditTests.moc"
|