Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 06:30:26 -04:00
1 parent 462fe805b1
commit 0b7eb6cb6f
29 files changed
+1535 -71

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
#include "AcceptanceFixtures.h"
#include "SelectionCanvasFixtures.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QLabel>
#include <QPushButton>
#include <QSlider>
// Swift 1.3.4's Color Range: canvas and panel.
namespace {
// Red left, blue right, filling the canvas.
struct Ranged : Canvas {
Ranged()
{
QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < 300; ++y)
for (int x = 200; x < 400; ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
session.insert(ImportedImage(image, image, QStringLiteral("Halves")));
session.beginColorRange();
canvas->synchronizeDisplay();
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
};
QWidget *panel()
{
for (QWidget *widget : QApplication::topLevelWidgets())
if (widget->objectName() == QStringLiteral("colorRangePanel") && widget->isVisible())
return widget;
return nullptr;
}
}
class ColorRangeCanvasTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void aClickTakesTheColourShiftAddsAltTakesAway();
void theEyedropperSaysWhatAClickWillDo();
void theSheetDrivesTheEdit();
void thePanelFollowsTheEditAndTheMenu();
};
void ColorRangeCanvasTests::aClickTakesTheColourShiftAddsAltTakesAway()
{
Ranged shown;
shown.click(QPointF(50, 50));
QCOMPARE(shown.edit().include, (std::vector<uchar>{200, 30, 30}));
shown.click(QPointF(300, 50), Qt::ShiftModifier);
QCOMPARE(shown.edit().include, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
shown.click(QPointF(50, 50), Qt::AltModifier);
QCOMPARE(shown.edit().exclude, (std::vector<uchar>{200, 30, 30}));
// Alt with Shift takes away too, as Swift's order reads.
shown.click(QPointF(300, 50), Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(shown.edit().exclude, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
// With Space held the press pans instead.
const QSizeF pan = shown.session.viewport.pan;
QTest::keyPress(shown.canvas, Qt::Key_Space);
shown.drag(QPointF(100, 100), QPointF(110, 120));
QTest::keyRelease(shown.canvas, Qt::Key_Space);
QCOMPARE(shown.session.viewport.pan, pan + QSizeF(10, 20));
QCOMPARE(shown.edit().exclude.size(), size_t(6));
// The match lands on the canvas as the selection.
shown.click(QPointF(300, 50));
QTRY_VERIFY(shown.session.selection() && shown.coverage(300, 50) == 255);
QCOMPARE(shown.coverage(50, 50), 0);
// Under the Eyedropper the click is the range's alone.
shown.session.cancelColorRange();
shown.session.selectTool(NavigationTool::eyedropper);
shown.session.beginColorRange();
const PaletteColor foreground = shown.session.foregroundColor();
shown.click(QPointF(50, 50));
QVERIFY(shown.session.foregroundColor() == foreground && shown.edit().include == (std::vector<uchar>{200, 30, 30}));
}
void ColorRangeCanvasTests::theEyedropperSaysWhatAClickWillDo()
{
Ranged shown;
const double ratio = shown.canvas->devicePixelRatio();
const QCursor plain = CanvasView::eyedropperCursor(ratio), add = CanvasView::eyedropperCursor(ratio, HueSampleMode::add),
remove = CanvasView::eyedropperCursor(ratio, HueSampleMode::remove);
QVERIFY(shown.shows(plain));
// A disc bottom right: white, a black sign across.
const QImage badged = image(add);
const auto gray = [ratio](const QCursor &cursor, QPoint at) { return qGray(image(cursor).pixel(at * ratio)); };
QVERIFY(gray(add, QPoint(20, 16)) > 230 && gray(add, QPoint(18, 18)) < 80 && gray(remove, QPoint(18, 18)) < 80);
// A plus has its upright; a minus has none.
QVERIFY(gray(add, QPoint(18, 15)) < 80 && gray(remove, QPoint(18, 15)) > 230);
// The bar spans the disc; a black rim rings it.
QVERIFY(gray(add, QPoint(16, 18)) < 80 && gray(remove, QPoint(20, 18)) < 80 && gray(remove, QPoint(18, 23)) < 80);
QCOMPARE(image(plain).pixelColor(QPoint(20, 16) * ratio).alpha(), 0);
// Held keys show it; the chosen eyedropper too.
QTest::keyPress(shown.canvas, Qt::Key_Shift);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::add));
QVERIFY(shown.shows(add));
QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::ShiftModifier);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::remove));
QVERIFY(shown.shows(remove));
// QTest would let go of Shift too: Alt alone goes.
QKeyEvent alt(QEvent::KeyRelease, Qt::Key_Alt, Qt::ShiftModifier);
QApplication::sendEvent(shown.canvas, &alt);
QCOMPARE(shown.edit().held, std::optional(HueSampleMode::add));
QTest::keyRelease(shown.canvas, Qt::Key_Shift);
QCOMPARE(shown.edit().held, std::optional<HueSampleMode>());
QVERIFY(shown.shows(plain));
shown.session.setColorRangeSampleMode(HueSampleMode::remove);
shown.canvas->synchronizeDisplay();
QVERIFY(shown.shows(remove));
// Closed, the tool's own cursor returns.
shown.session.cancelColorRange();
shown.canvas->synchronizeDisplay();
QVERIFY(!shown.shows(plain) && !shown.shows(remove));
}
void ColorRangeCanvasTests::theSheetDrivesTheEdit()
{
Ranged shown;
ColorRangeSheet sheet(shown.session);
sheet.show();
QVERIFY(QTest::qWaitForWindowExposed(&sheet));
const auto dropper = [&sheet](const char *mode) { return sheet.findChild<SampleButton *>(QStringLiteral("colorRange") + QString::fromLatin1(mode)); };
QVERIFY(dropper("Sample")->isChecked() && !dropper("Add")->isChecked());
QCOMPARE(dropper("Add")->toolTip(), QStringLiteral("Click the image to add that color to the selection"));
QCOMPARE(dropper("Remove")->accessibleName(), QStringLiteral("Remove color"));
dropper("Add")->click();
QVERIFY(shown.edit().sampleMode == HueSampleMode::add && dropper("Add")->isChecked() && !dropper("Sample")->isChecked());
// Held keys light the eyedropper a click will use.
shown.session.setColorRangeHeld(HueSampleMode::remove);
QVERIFY(dropper("Remove")->isChecked() && !dropper("Add")->isChecked());
shown.session.setColorRangeHeld(std::nullopt);
QLabel *caption = sheet.findChild<QLabel *>(QStringLiteral("colorRangeCaption"));
QCOMPARE(caption->text(), QStringLiteral("Click the image to pick the color to select."));
shown.session.sampleColorRange(QPointF(50, 50), false, false);
QCOMPARE(caption->text(), QStringLiteral("Shift-click adds a color, Alt-click takes one away."));
// Fuzziness: the slider, the field, the title's scrub.
QSlider *slider = sheet.findChild<QSlider *>(QStringLiteral("fuzzinessSlider"));
QVERIFY(slider->minimum() == 0 && slider->maximum() == 200 && slider->value() == 40);
slider->setValue(120);
QCOMPARE(shown.edit().fuzziness, 120.0);
PickerField *field = sheet.findChild<PickerField *>(QStringLiteral("fuzzinessField"));
QCOMPARE(field->text(), QStringLiteral("120"));
field->setFocus();
field->selectAll();
QTest::keyClicks(field, QStringLiteral("33.6"));
QTest::keyClick(field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 34 && slider->value() == 34);
QLabel *title = nullptr;
for (QLabel *label : sheet.findChildren<QLabel *>())
title = label->text() == QStringLiteral("Fuzziness") ? label : title;
QVERIFY(title && title->buddy() == slider);
QTest::mousePress(title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
QMouseEvent move(QEvent::MouseMove, QPointF(22, 2), title->mapToGlobal(QPointF(22, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(title, &move);
QTest::mouseRelease(title, Qt::LeftButton, Qt::NoModifier, QPoint(22, 2));
QCOMPARE(shown.edit().fuzziness, 54.0);
// Invert, and an error shown in the warning colour.
QCheckBox *invert = sheet.findChild<QCheckBox *>(QStringLiteral("colorRangeInvert"));
invert->click();
QVERIFY(shown.edit().invert);
QLabel *error = sheet.findChild<QLabel *>(QStringLiteral("colorRangeError"));
QVERIFY(error->isHidden() && error->foregroundRole() == QPalette::BrightText);
// OK keeps the match as a step; the edit closes.
QTRY_VERIFY(!shown.edit().preview.isNull());
QPushButton *ok = nullptr;
for (QPushButton *button : sheet.findChildren<QPushButton *>())
ok = button->text() == QStringLiteral("OK") ? button : ok;
QVERIFY(ok && ok->isDefault());
ok->click();
QVERIFY(!shown.session.colorRange());
QCOMPARE(shown.session.history.undoName(), QStringLiteral("Color Range"));
}
void ColorRangeCanvasTests::thePanelFollowsTheEditAndTheMenu()
{
App app;
QVERIFY(scene(400, 300).save(app.path("scene.png")));
app.session().createDocument(400, 300);
app.importFile(app.path("scene.png"));
QAction &entry = app.action("colorRange");
QCOMPARE(entry.text(), QStringLiteral("Color Range…"));
QVERIFY(entry.isEnabled());
entry.trigger();
QVERIFY(app.session().colorRange());
QTRY_VERIFY(panel());
QCOMPARE(panel()->windowTitle(), QStringLiteral("Color Range"));
QVERIFY(panel()->findChild<ColorRangeSheet *>());
QVERIFY(!entry.isEnabled());
// Escape is Cancel: the panel and the edit go.
QTRY_COMPARE(QApplication::activeWindow(), panel());
QTest::keyClick(panel(), Qt::Key_Escape);
QTRY_VERIFY(!panel());
QVERIFY(!app.session().colorRange() && entry.isEnabled());
// Reopened: a canvas click samples; the panel keeps the keys.
entry.trigger();
QTRY_VERIFY(panel());
app.window.activateWindow();
QTRY_COMPARE(QApplication::activeWindow(), &app.window);
app.press(QPointF(200, 150));
app.release(QPointF(200, 150));
QVERIFY(app.session().colorRange().value().hasColors());
QTRY_COMPARE(QApplication::activeWindow(), panel());
app.session().commitColorRange();
QTRY_VERIFY(!panel());
}
QTEST_MAIN(ColorRangeCanvasTests)
#include "ColorRangeCanvasTests.moc"
+233
View File
@@ -0,0 +1,233 @@
#include "Document/EditorSession.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QDialog>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSlider>
#include <QVBoxLayout>
#include <QtTest>
// Swift 1.3.4's ColorRangeSheet: its layout, field, preview, words.
namespace {
// Red left, blue right; the sheet over an open edit.
struct Sheet {
EditorSession session;
std::unique_ptr<ColorRangeSheet> sheet;
Sheet(QSize size = QSize(400, 300))
{
QImage image(size, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < size.height(); ++y)
for (int x = size.width() / 2; x < size.width(); ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
session.createDocument(size.width(), size.height());
session.insert(ImportedImage(image, image, QStringLiteral("Halves")));
session.beginColorRange();
sheet = std::make_unique<ColorRangeSheet>(session);
sheet->show();
if (!QTest::qWaitForWindowActive(sheet.get()))
throw std::runtime_error("the sheet never became active");
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
template <typename Widget> Widget &child(const char *name) const
{
Widget *found = sheet->findChild<Widget *>(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(std::string("no child ") + name);
return *found;
}
// A click on the left half, and its match landing.
void sampleRed()
{
QSignalSpy changed(&session, &EditorSession::changed);
session.sampleColorRange(QPointF(10, 10), false, false);
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
}
};
}
class ColorRangeSheetTests : public QObject {
Q_OBJECT
private slots:
void theSheetLaysOutAsSwifts();
void theFieldTakesTypingAsSwiftsBinding();
void thePreviewPaintsTheMatch();
void thePreviewShrinksSmoothly();
void invertAndErrorsFollowTheEdit();
};
void ColorRangeSheetTests::theSheetLaysOutAsSwifts()
{
Sheet shown;
auto *column = qobject_cast<QVBoxLayout *>(shown.sheet->layout());
QVERIFY(column && shown.sheet->width() == 340 && column->spacing() == 16);
QCOMPARE(column->contentsMargins(), QMargins(24, 24, 24, 24));
// The eyedroppers in Swift's order, 6 apart, then a stretch.
auto *droppers = qobject_cast<QHBoxLayout *>(column->itemAt(0)->layout());
QVERIFY(droppers && droppers->spacing() == 6 && droppers->count() == 4 && droppers->itemAt(3)->spacerItem());
QCOMPARE(droppers->itemAt(0)->widget(), &shown.child<SampleButton>("colorRangeSample"));
QCOMPARE(droppers->itemAt(2)->widget(), &shown.child<SampleButton>("colorRangeRemove"));
// The preview, centred, the canvas's shape within 292 by 200.
QWidget *preview = column->itemAt(1)->widget();
QVERIFY(preview && column->itemAt(1)->alignment() == Qt::AlignHCenter);
QCOMPARE(preview->size(), QSize(267, 200));
QCOMPARE(preview->mapTo(shown.sheet.get(), QPoint()).x(), (340 - 267) / 2);
// The caption: wrapped, callout-sized, secondary.
auto &caption = shown.child<QLabel>("colorRangeCaption");
QVERIFY(column->itemAt(2)->widget() == &caption && caption.wordWrap() && caption.font().pixelSize() == 12);
QCOMPARE(caption.foregroundRole(), QPalette::PlaceholderText);
// Fuzziness: title, slider stretching, a 48-point field.
QWidget *row = column->itemAt(3)->widget();
QCOMPARE(row->toolTip(), QStringLiteral("How far a color may be from the picked ones and still be selected"));
auto *fuzziness = qobject_cast<QHBoxLayout *>(row->layout());
QVERIFY(fuzziness->spacing() == 10 && fuzziness->contentsMargins() == QMargins() && fuzziness->count() == 3);
QCOMPARE(qobject_cast<QLabel *>(fuzziness->itemAt(0)->widget())->text(), QStringLiteral("Fuzziness"));
QVERIFY(fuzziness->itemAt(1)->widget() == &shown.child<QSlider>("fuzzinessSlider") && fuzziness->stretch(1) == 1);
auto &field = shown.child<PickerField>("fuzzinessField");
QVERIFY(fuzziness->itemAt(2)->widget() == &field && field.width() == 48 && field.alignment() == Qt::AlignRight);
QVERIFY(field.accessibleName() == QStringLiteral("Fuzziness") && field.placeholderText() == QStringLiteral("Fuzziness"));
// Invert, the error, a rule, then Cancel and OK apart.
auto &invert = shown.child<QCheckBox>("colorRangeInvert");
QVERIFY(column->itemAt(4)->widget() == &invert);
QCOMPARE(invert.toolTip(), QStringLiteral("Select everything except those colors, such as all but a green screen"));
auto &error = shown.child<QLabel>("colorRangeError");
QVERIFY(column->itemAt(5)->widget() == &error && error.wordWrap());
auto *rule = qobject_cast<QFrame *>(column->itemAt(6)->widget());
QVERIFY(rule && rule->frameShape() == QFrame::HLine && rule->foregroundRole() == QPalette::Mid);
auto *buttons = qobject_cast<QHBoxLayout *>(column->itemAt(7)->layout());
QVERIFY(buttons && buttons->count() == 3 && buttons->itemAt(1)->spacerItem());
auto *cancel = qobject_cast<QPushButton *>(buttons->itemAt(0)->widget());
auto *ok = qobject_cast<QPushButton *>(buttons->itemAt(2)->widget());
QVERIFY(cancel->text() == QStringLiteral("Cancel") && ok->text() == QStringLiteral("OK") && ok->isDefault());
// In a panel, a dialog, only OK takes Return.
QDialog panel;
shown.sheet->setParent(&panel);
QVERIFY(!cancel->autoDefault() && ok->autoDefault());
shown.sheet->setParent(nullptr);
// Cancel puts the old selection back and closes the edit.
cancel->click();
QVERIFY(!shown.session.colorRange());
}
void ColorRangeSheetTests::theFieldTakesTypingAsSwiftsBinding()
{
Sheet shown;
auto &field = shown.child<PickerField>("fuzzinessField");
auto &slider = shown.child<QSlider>("fuzzinessSlider");
QVERIFY(field.text() == QStringLiteral("40") && slider.value() == 40);
// Typed, then Return: whole numbers, shown whole.
field.setFocus();
QTRY_VERIFY(field.hasFocus());
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("40.4"));
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 40 && field.text() == QStringLiteral("40"));
// The slider replaces typing, as the title's scrub does.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("77"));
slider.setValue(90);
QVERIFY(shown.edit().fuzziness == 90 && field.text() == QStringLiteral("90"));
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("77"));
QLabel *title = qobject_cast<QLabel *>(qobject_cast<QHBoxLayout *>(field.parentWidget()->layout())->itemAt(0)->widget());
QTest::mousePress(title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
QMouseEvent move(QEvent::MouseMove, QPointF(12, 2), title->mapToGlobal(QPointF(12, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(title, &move);
QTest::mouseRelease(title, Qt::LeftButton, Qt::NoModifier, QPoint(12, 2));
QVERIFY(shown.edit().fuzziness == 100 && field.text() == QStringLiteral("100"));
// Text that is no number goes back.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("lots"));
QTest::keyClick(&field, Qt::Key_Return);
QVERIFY(shown.edit().fuzziness == 100 && field.text() == QStringLiteral("100"));
// The sheet going commits what its field holds.
field.selectAll();
QTest::keyClicks(&field, QStringLiteral("123"));
shown.sheet.reset();
QCOMPARE(shown.edit().fuzziness, 123.0);
}
void ColorRangeSheetTests::thePreviewPaintsTheMatch()
{
Sheet shown;
QWidget *preview = shown.sheet->layout()->itemAt(1)->widget();
// Black before a colour, inside a faint white rim.
QImage drawn = preview->grab().toImage();
QCOMPARE(drawn.pixelColor(100, 100), QColor(Qt::black));
QCOMPARE(qGray(drawn.pixel(0, 100)), 51);
// A match repaints it: white over the red half.
int painted = 0;
struct Counter : QObject {
int &count;
explicit Counter(int &count) : count(count) {}
bool eventFilter(QObject *, QEvent *event) override
{
count += event->type() == QEvent::Paint;
return false;
}
} counter(painted);
preview->installEventFilter(&counter);
shown.sampleRed();
QTRY_VERIFY(painted > 0);
drawn = preview->grab().toImage();
QVERIFY(qGray(drawn.pixel(60, 100)) > 240 && qGray(drawn.pixel(200, 100)) < 15);
}
void ColorRangeSheetTests::thePreviewShrinksSmoothly()
{
// Red then stripes a pixel wide: the preview halves them.
QImage image(584, 400, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < 400; ++y)
for (int x = 0; x < 584; ++x)
image.setPixelColor(x, y, x < 40 || x % 2 ? QColor(200, 30, 30) : QColor(30, 40, 210));
EditorSession session;
session.createDocument(584, 400);
session.insert(ImportedImage(image, image, QStringLiteral("Stripes")));
session.beginColorRange();
ColorRangeSheet sheet(session);
QWidget *preview = sheet.layout()->itemAt(1)->widget();
QCOMPARE(preview->size(), QSize(292, 200));
QSignalSpy changed(&session, &EditorSession::changed);
session.sampleColorRange(QPointF(10, 10), false, false);
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
const QImage drawn = preview->grab().toImage();
QVERIFY(qGray(drawn.pixel(10, 100)) > 240);
QVERIFY(std::abs(qGray(drawn.pixel(150, 100)) - 128) < 20);
}
void ColorRangeSheetTests::invertAndErrorsFollowTheEdit()
{
Sheet shown;
auto &invert = shown.child<QCheckBox>("colorRangeInvert");
shown.session.setColorRangeInvert(true);
QVERIFY(invert.isChecked());
shown.session.setColorRangeInvert(false);
QVERIFY(!invert.isChecked());
// A checkerboard past the edge limit: the error shows.
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;
}
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
ColorRangeSheet sheet(session);
auto *error = sheet.findChild<QLabel *>(QStringLiteral("colorRangeError"));
QVERIFY(error->isHidden());
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(1, 1), false, false);
QTRY_VERIFY_WITH_TIMEOUT(!error->isHidden(), 30000);
QCOMPARE(error->text(), QString::fromUtf8(MagicWandError(MagicWandError::Kind::tooDetailed).what()));
QCOMPARE(error->foregroundRole(), QPalette::BrightText);
}
QTEST_MAIN(ColorRangeSheetTests)
#include "ColorRangeSheetTests.moc"
+403
View File
@@ -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"
+5 -2
View File
@@ -34,7 +34,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"colorRange", ""}, {"maskBlackAreas", ""},
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
@@ -53,7 +53,10 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QCOMPARE(menus[0]->actions().size(), 12);
QCOMPARE(menus[1]->actions().size(), 13);
QCOMPARE(menus[2]->actions().size(), 16);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[3]->actions().size(), 11);
// Swift 1.3.4's Color Range… follows Subject.
const QList<QAction *> select = menus[3]->actions();
QCOMPARE(select.indexOf(&bar.action("colorRange")), select.indexOf(&bar.action("subject")) + 1);
QCOMPARE(menus[4]->actions().size(), 16);
// Swift's Trim… follows Image Size….
const QList<QAction *> image = menus[4]->actions();
+4
View File
@@ -1,6 +1,7 @@
#include "InlineTextFixtures.h"
#include "UI/CanvasSizeSheet.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorRangeSheet.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
#include "UI/HueSaturationSheet.h"
@@ -245,6 +246,9 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys()
session.beginFilter(FilterKind::gaussianBlur);
QCOMPARE(keys(FilterSheet(session)), both);
session.cancelFilter();
session.beginColorRange();
QCOMPARE(keys(ColorRangeSheet(session)), both);
session.cancelColorRange();
}
void RemappedKeysTests::aKeyLeftAloneReachesTheParent()
+5
View File
@@ -175,6 +175,11 @@ compositor_test(TextColorEditorTests)
compositor_test(TextEditingGatesTests)
compositor_test(TextFontRunsTests)
compositor_test(TextFontControlsTests)
compositor_test(ColorRangeTests)
compositor_test(ColorRangeCanvasTests)
compositor_test(ColorRangeSheetTests)
# It calls the colour-range kernel itself.
target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(DitherTests)
compositor_test(DitherSheetTests)
compositor_test(DitherKernelTests)