Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b)
This commit is contained in:
1 parent
462fe805b1
commit
0b7eb6cb6f
29 files changed
+1535
-71
No files matched your search
@@ -0,0 +1,403 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "SelectionFixtures.h"
|
||||
#include <QFutureWatcher>
|
||||
#include <QtTest>
|
||||
|
||||
extern "C" {
|
||||
#include "WandPixels.h"
|
||||
}
|
||||
|
||||
// Swift 1.3.4's Color Range: model, kernel, session.
|
||||
namespace {
|
||||
// Red, a band of blue, and a clear corner.
|
||||
QImage picture()
|
||||
{
|
||||
QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied);
|
||||
image.fill(QColor(200, 30, 30));
|
||||
for (int y = 0; y < 20; ++y)
|
||||
for (int x = 20; x < 30; ++x)
|
||||
image.setPixelColor(x, y, QColor(30, 40, 210));
|
||||
for (int y = 0; y < 5; ++y)
|
||||
for (int x = 35; x < 40; ++x)
|
||||
image.setPixelColor(x, y, Qt::transparent);
|
||||
return image;
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
EditorSession session;
|
||||
Opened()
|
||||
{
|
||||
session.createDocument(40, 20);
|
||||
session.insert(ImportedImage(picture(), picture(), QStringLiteral("Picture")));
|
||||
session.beginColorRange();
|
||||
}
|
||||
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
|
||||
// A change, then its match landing: a second signal.
|
||||
void land(const std::function<void()> &change)
|
||||
{
|
||||
QSignalSpy changed(&session, &EditorSession::changed);
|
||||
change();
|
||||
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
|
||||
}
|
||||
void sample(QPointF point, bool shift = false, bool option = false)
|
||||
{
|
||||
land([&] { session.sampleColorRange(point, shift, option); });
|
||||
}
|
||||
int covered(int x, int y) const { return coverage(session, x, y); }
|
||||
};
|
||||
|
||||
std::vector<uchar> rgb(int r, int g, int b)
|
||||
{
|
||||
return {uchar(r), uchar(g), uchar(b)};
|
||||
}
|
||||
|
||||
// A 2100 checkerboard, black in its corner: past the limit.
|
||||
QImage checkerboard()
|
||||
{
|
||||
QImage board(2100, 2100, QImage::Format_RGBA8888_Premultiplied);
|
||||
for (int y = 0; y < board.height(); ++y) {
|
||||
auto *row = reinterpret_cast<quint32 *>(board.scanLine(y));
|
||||
for (int x = 0; x < board.width(); ++x)
|
||||
row[x] = (x + y) % 2 && (x > 2 || y > 2) ? 0xFFFFFFFF : 0xFF000000;
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
// The session's workers: its own watchers and one a change.
|
||||
int watchers(const EditorSession &session)
|
||||
{
|
||||
return int(session.findChildren<QFutureWatcherBase *>(Qt::FindDirectChildrenOnly).size());
|
||||
}
|
||||
}
|
||||
|
||||
class ColorRangeTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theKernelMatchesWithinFuzziness();
|
||||
void aClickPicksTheAverageAroundIt();
|
||||
void addTakeAwayAndStartOver();
|
||||
void fuzzinessInvertAndThePreview();
|
||||
void okIsOneStepAndCancelPutsBack();
|
||||
void theGatesWaitForThePanel();
|
||||
void everyChangeIsAnnounced();
|
||||
void anOutlineTooDetailedIsShownAndKeptOut();
|
||||
void anErrorKeepsTheLastMatch();
|
||||
void onlyTheNewestMatchLands();
|
||||
void thePreviewTruncatesAsSwifts();
|
||||
};
|
||||
|
||||
void ColorRangeTests::theKernelMatchesWithinFuzziness()
|
||||
{
|
||||
// Four pixels: red, red off by 10, blue, clear.
|
||||
const std::array<uchar, 16> pixels{200, 30, 30, 255, 210, 30, 30, 255, 30, 40, 210, 255, 0, 0, 0, 0};
|
||||
std::array<uchar, 4> mask{};
|
||||
const std::vector<uchar> red = rgb(200, 30, 30);
|
||||
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 9, 0, mask.data()), 1L);
|
||||
QCOMPARE(mask, (std::array<uchar, 4>{255, 0, 0, 0}));
|
||||
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 0, mask.data()), 2L);
|
||||
QCOMPARE(mask, (std::array<uchar, 4>{255, 255, 0, 0}));
|
||||
// Taken away wins; inverted, clear pixels are selected too.
|
||||
const std::vector<uchar> off = rgb(210, 30, 30);
|
||||
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, off.data(), 1, 10, 0, mask.data()), 0L);
|
||||
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 1, mask.data()), 2L);
|
||||
QCOMPARE(mask, (std::array<uchar, 4>{0, 0, 255, 255}));
|
||||
// Premultiplied pixels are read straight: half clear red matches red.
|
||||
const std::array<uchar, 4> half{100, 15, 15, 128};
|
||||
std::array<uchar, 1> one{};
|
||||
QCOMPARE(color_range_mask(half.data(), 1, 1, 4, red.data(), 1, nullptr, 0, 1, 0, one.data()), 1L);
|
||||
}
|
||||
|
||||
void ColorRangeTests::aClickPicksTheAverageAroundIt()
|
||||
{
|
||||
Opened opened;
|
||||
QVERIFY(!opened.edit().hasColors() && opened.edit().preview.isNull());
|
||||
opened.sample(QPointF(5.7, 10.2));
|
||||
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
|
||||
// On the seam: three red and six blue, averaged.
|
||||
opened.sample(QPointF(20, 10));
|
||||
QCOMPARE(opened.edit().include, rgb((3 * 200 + 6 * 30 + 4) / 9, (3 * 30 + 6 * 40 + 4) / 9, (3 * 30 + 6 * 210 + 4) / 9));
|
||||
// At the clear corner's edge only coloured pixels count.
|
||||
opened.sample(QPointF(34, 2));
|
||||
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
|
||||
// The click's pixel is floored: 19.6 reads round 19.
|
||||
opened.sample(QPointF(19.6, 10));
|
||||
QCOMPARE(opened.edit().include, rgb((6 * 200 + 3 * 30 + 4) / 9, (6 * 30 + 3 * 40 + 4) / 9, (6 * 30 + 3 * 210 + 4) / 9));
|
||||
// In the clear corner, past the edge, no number: nothing.
|
||||
for (const QPointF point : {QPointF(38, 2), QPointF(-1, 3), QPointF(40, 3), QPointF(qQNaN(), 3)}) {
|
||||
const int generation = opened.edit().generation;
|
||||
opened.session.sampleColorRange(point, false, false);
|
||||
QCOMPARE(opened.edit().generation, generation);
|
||||
}
|
||||
// Three rows, one of each colour: all three count.
|
||||
QImage rows(3, 3, QImage::Format_RGBA8888_Premultiplied);
|
||||
for (int x = 0; x < 3; ++x) {
|
||||
rows.setPixelColor(x, 0, QColor(90, 0, 0));
|
||||
rows.setPixelColor(x, 1, QColor(0, 90, 0));
|
||||
rows.setPixelColor(x, 2, QColor(0, 0, 90));
|
||||
}
|
||||
EditorSession session;
|
||||
session.createDocument(3, 3);
|
||||
session.insert(ImportedImage(rows, rows, QStringLiteral("Rows")));
|
||||
session.beginColorRange();
|
||||
session.sampleColorRange(QPointF(1, 1), false, false);
|
||||
QCOMPARE(session.colorRange().value().include, rgb(30, 30, 30));
|
||||
}
|
||||
|
||||
void ColorRangeTests::addTakeAwayAndStartOver()
|
||||
{
|
||||
Opened opened;
|
||||
opened.sample(QPointF(5, 5));
|
||||
QCOMPARE(opened.covered(5, 5), 255);
|
||||
QCOMPARE(opened.covered(25, 5), 0);
|
||||
QVERIFY(opened.session.selection().value().antialiased);
|
||||
// The match takes the session's antialiasing.
|
||||
opened.session.setSelectionAntialiased(false);
|
||||
opened.sample(QPointF(6, 5));
|
||||
QVERIFY(!opened.session.selection().value().antialiased);
|
||||
// Shift adds blue; Alt takes red away.
|
||||
opened.sample(QPointF(25, 5), true, false);
|
||||
QCOMPARE(opened.edit().include, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
|
||||
QCOMPARE(opened.covered(25, 5), 255);
|
||||
opened.sample(QPointF(5, 5), true, true);
|
||||
QCOMPARE(opened.edit().exclude, rgb(200, 30, 30));
|
||||
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255);
|
||||
// The chosen eyedroppers act without keys; replace starts over.
|
||||
opened.session.setColorRangeSampleMode(HueSampleMode::remove);
|
||||
opened.sample(QPointF(25, 5));
|
||||
QCOMPARE(opened.edit().exclude, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
|
||||
QVERIFY(!opened.session.selection());
|
||||
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
|
||||
opened.sample(QPointF(25, 5));
|
||||
QVERIFY(opened.edit().include == rgb(30, 40, 210) && opened.edit().exclude.empty());
|
||||
QCOMPARE(opened.covered(25, 5), 255);
|
||||
// Held keys outrank the chosen eyedropper.
|
||||
opened.session.setColorRangeHeld(HueSampleMode::add);
|
||||
QCOMPARE(opened.edit().effectiveMode(), HueSampleMode::add);
|
||||
}
|
||||
|
||||
void ColorRangeTests::fuzzinessInvertAndThePreview()
|
||||
{
|
||||
Opened opened;
|
||||
opened.sample(QPointF(5, 5));
|
||||
// The preview: the mask at twice the panel's fit.
|
||||
const double scale = std::min(292.0 / 40, 200.0 / 20) * 2;
|
||||
QCOMPARE(opened.edit().preview.size(), QSize(int(40 * scale), int(20 * scale)));
|
||||
QCOMPARE(qGray(opened.edit().preview.pixel(10, 10)), 255);
|
||||
QCOMPARE(qGray(opened.edit().preview.pixel(int(25 * scale), 10)), 0);
|
||||
// Fuzziness: whole, 0 to 200; wide, blue joins.
|
||||
opened.land([&] { opened.session.setColorRangeFuzziness(250.4); });
|
||||
QCOMPARE(opened.edit().fuzziness, 200.0);
|
||||
QCOMPARE(opened.covered(25, 5), 255);
|
||||
opened.session.setColorRangeFuzziness(-3);
|
||||
QCOMPARE(opened.edit().fuzziness, 0.0);
|
||||
opened.session.setColorRangeFuzziness(12.6);
|
||||
QCOMPARE(opened.edit().fuzziness, 13.0);
|
||||
// Invert: all but red, the clear corner included.
|
||||
opened.land([&] { opened.session.setColorRangeInvert(true); });
|
||||
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255 && opened.covered(38, 2) == 255);
|
||||
// No colours: the old selection and no preview.
|
||||
Opened empty;
|
||||
empty.session.updateColorRange();
|
||||
QVERIFY(!empty.session.selection() && empty.edit().preview.isNull());
|
||||
}
|
||||
|
||||
void ColorRangeTests::okIsOneStepAndCancelPutsBack()
|
||||
{
|
||||
Opened opened;
|
||||
QPainterPath corner;
|
||||
corner.addRect(0, 0, 4, 4);
|
||||
const DocumentSelection before{corner, true};
|
||||
opened.session.cancelColorRange();
|
||||
opened.session.setSelection(before, QStringLiteral("Before"));
|
||||
opened.session.beginColorRange();
|
||||
QCOMPARE(opened.edit().original.value().path, before.path);
|
||||
opened.sample(QPointF(25, 5));
|
||||
QCOMPARE(opened.covered(1, 1), 0);
|
||||
// Cancel: the selection there was, no step.
|
||||
const int steps = opened.session.history.undoCount();
|
||||
opened.session.cancelColorRange();
|
||||
QVERIFY(!opened.session.colorRange() && opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
|
||||
QCOMPARE(opened.session.history.undoCount(), steps);
|
||||
// OK: one step, named; undo puts the old back.
|
||||
opened.session.beginColorRange();
|
||||
opened.sample(QPointF(25, 5));
|
||||
opened.session.commitColorRange();
|
||||
QCOMPARE(opened.session.history.undoCount(), steps + 1);
|
||||
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Color Range"));
|
||||
QCOMPARE(opened.covered(25, 5), 255);
|
||||
opened.session.undo();
|
||||
QVERIFY(opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
|
||||
// OK with no colour keeps the old selection, no step.
|
||||
opened.session.beginColorRange();
|
||||
opened.session.commitColorRange();
|
||||
QCOMPARE(opened.session.history.undoCount(), steps);
|
||||
QCOMPARE(opened.covered(1, 1), 255);
|
||||
// OK on a match of nothing deselects.
|
||||
opened.session.beginColorRange();
|
||||
opened.sample(QPointF(5, 5));
|
||||
opened.sample(QPointF(5, 5), false, true);
|
||||
QVERIFY(!opened.session.selection());
|
||||
opened.session.commitColorRange();
|
||||
QVERIFY(!opened.session.selection());
|
||||
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Deselect"));
|
||||
}
|
||||
|
||||
void ColorRangeTests::theGatesWaitForThePanel()
|
||||
{
|
||||
Opened opened;
|
||||
QVERIFY(!opened.session.canEditLayers() && !opened.session.canUseHistory() && !opened.session.canStartProjectOperation());
|
||||
QVERIFY(!opened.session.canSelectColorRange());
|
||||
// A second begin keeps the open edit.
|
||||
const QUuid id = opened.edit().id;
|
||||
opened.session.beginColorRange();
|
||||
QCOMPARE(opened.edit().id, id);
|
||||
opened.session.cancelColorRange();
|
||||
QVERIFY(opened.session.canEditLayers() && opened.session.canUseHistory() && opened.session.canStartProjectOperation());
|
||||
QVERIFY(opened.session.canSelectColorRange());
|
||||
// A request waiting on the panel runs once it closes.
|
||||
opened.session.beginColorRange();
|
||||
bool ran = false;
|
||||
opened.session.waitForFileRequest([&ran] { ran = true; });
|
||||
QTest::qWait(10);
|
||||
QVERIFY(!ran);
|
||||
opened.session.cancelColorRange();
|
||||
QTRY_VERIFY(ran);
|
||||
// OK frees one too.
|
||||
opened.session.beginColorRange();
|
||||
ran = false;
|
||||
opened.session.waitForFileRequest([&ran] { ran = true; });
|
||||
QTest::qWait(10);
|
||||
QVERIFY(!ran);
|
||||
opened.session.commitColorRange();
|
||||
QTRY_VERIFY(ran);
|
||||
// Without a document there is nothing to open.
|
||||
EditorSession empty;
|
||||
QVERIFY(!empty.canSelectColorRange());
|
||||
empty.beginColorRange();
|
||||
QVERIFY(!empty.colorRange());
|
||||
}
|
||||
|
||||
void ColorRangeTests::everyChangeIsAnnounced()
|
||||
{
|
||||
Opened opened;
|
||||
QSignalSpy changed(&opened.session, &EditorSession::changed);
|
||||
// Before a colour, Fuzziness and Invert still say so.
|
||||
opened.session.setColorRangeFuzziness(50);
|
||||
QCOMPARE(changed.count(), 1);
|
||||
opened.session.setColorRangeInvert(true);
|
||||
QCOMPARE(changed.count(), 2);
|
||||
opened.session.setColorRangeInvert(false);
|
||||
// Each eyedropper and held key; an unchanged one is silent.
|
||||
changed.clear();
|
||||
opened.session.setColorRangeSampleMode(HueSampleMode::add);
|
||||
opened.session.setColorRangeSampleMode(HueSampleMode::add);
|
||||
opened.session.setColorRangeHeld(HueSampleMode::remove);
|
||||
opened.session.setColorRangeHeld(HueSampleMode::remove);
|
||||
opened.session.setColorRangeFuzziness(50);
|
||||
QCOMPARE(changed.count(), 2);
|
||||
// A click says so now, and as its match lands.
|
||||
opened.session.setColorRangeHeld(std::nullopt);
|
||||
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
|
||||
changed.clear();
|
||||
opened.session.sampleColorRange(QPointF(5, 5), false, false);
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QTRY_COMPARE(changed.count(), 2);
|
||||
QCOMPARE(opened.covered(5, 5), 255);
|
||||
}
|
||||
|
||||
void ColorRangeTests::anOutlineTooDetailedIsShownAndKeptOut()
|
||||
{
|
||||
const QImage board = checkerboard();
|
||||
EditorSession session;
|
||||
session.createDocument(2100, 2100);
|
||||
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
|
||||
session.beginColorRange();
|
||||
session.setColorRangeFuzziness(0);
|
||||
session.sampleColorRange(QPointF(1, 1), false, false);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
|
||||
QCOMPARE(session.colorRange().value().error.value(), QString::fromUtf8(MagicWandError(MagicWandError::Kind::tooDetailed).what()));
|
||||
// The preview still shows; the selection stays as it was.
|
||||
QVERIFY(!session.colorRange().value().preview.isNull() && !session.selection());
|
||||
const int steps = session.history.undoCount();
|
||||
session.commitColorRange();
|
||||
QVERIFY(!session.selection() && !session.colorRange());
|
||||
QCOMPARE(session.history.undoCount(), steps);
|
||||
}
|
||||
|
||||
void ColorRangeTests::anErrorKeepsTheLastMatch()
|
||||
{
|
||||
// A red band on top; a black block amid checks.
|
||||
QImage board = checkerboard();
|
||||
for (int y = 0; y < 10; ++y)
|
||||
for (int x = 0; x < board.width(); ++x)
|
||||
board.setPixelColor(x, y, QColor(200, 30, 30));
|
||||
for (int y = 100; y < 103; ++y)
|
||||
for (int x = 100; x < 103; ++x)
|
||||
board.setPixelColor(x, y, Qt::black);
|
||||
EditorSession session;
|
||||
session.createDocument(2100, 2100);
|
||||
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
|
||||
session.beginColorRange();
|
||||
session.setColorRangeFuzziness(0);
|
||||
session.sampleColorRange(QPointF(10, 4), false, false);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(session.selection(), 30000);
|
||||
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
|
||||
// Black matches every dark check: too detailed, the band stays.
|
||||
session.sampleColorRange(QPointF(101, 101), false, false);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
|
||||
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
|
||||
// OK after an error: the old selection, no step.
|
||||
const int steps = session.history.undoCount();
|
||||
session.commitColorRange();
|
||||
QVERIFY(!session.selection() && session.history.undoCount() == steps);
|
||||
}
|
||||
|
||||
void ColorRangeTests::onlyTheNewestMatchLands()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(2100, 2100);
|
||||
const QImage board = checkerboard();
|
||||
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
|
||||
const int idle = watchers(session);
|
||||
session.beginColorRange();
|
||||
session.setColorRangeFuzziness(0);
|
||||
// A slow trace, then a gray that matches nothing, quickly.
|
||||
session.sampleColorRange(QPointF(1, 1), false, false);
|
||||
session.sampleColorRange(QPointF(3, 3), false, false);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
|
||||
QVERIFY(!session.colorRange().value().error && !session.colorRange().value().preview.isNull());
|
||||
// A closed edit's trace lands on no later edit.
|
||||
session.cancelColorRange();
|
||||
session.beginColorRange();
|
||||
session.sampleColorRange(QPointF(1, 1), false, false);
|
||||
session.cancelColorRange();
|
||||
session.beginColorRange();
|
||||
// Both at generation one: the edit's id tells them apart.
|
||||
session.sampleColorRange(QPointF(3, 3), false, false);
|
||||
QCOMPARE(session.colorRange().value().generation, 1);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
|
||||
QVERIFY(!session.colorRange().value().error);
|
||||
// Nor on another document.
|
||||
Opened opened;
|
||||
const int quiet = watchers(opened.session);
|
||||
opened.session.sampleColorRange(QPointF(5, 5), false, false);
|
||||
opened.session.createDocument(40, 20);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(watchers(opened.session), quiet, 30000);
|
||||
QVERIFY(!opened.session.selection() && opened.edit().preview.isNull());
|
||||
}
|
||||
|
||||
void ColorRangeTests::thePreviewTruncatesAsSwifts()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(400, 300);
|
||||
QImage red(400, 300, QImage::Format_RGBA8888_Premultiplied);
|
||||
red.fill(QColor(200, 30, 30));
|
||||
session.insert(ImportedImage(red, red, QStringLiteral("Red")));
|
||||
session.beginColorRange();
|
||||
session.sampleColorRange(QPointF(5, 5), false, false);
|
||||
QTRY_VERIFY(!session.colorRange().value().preview.isNull());
|
||||
QCOMPARE(session.colorRange().value().preview.size(), QSize(533, 400));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ColorRangeTests)
|
||||
#include "ColorRangeTests.moc"
|
||||
Reference in new issue
Block a user