Files
OmaPhoto/tests/BrushCursorOverlayTests.cpp
T
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
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>
2026-09-26 00:44:52 -04:00

243 lines
10 KiB
C++

#include "Rendering/BrushCursorOverlay.h"
#include <QImage>
#include <QtTest>
#include <cmath>
#include <numbers>
// Swift's brush circle: what it repaints, and how it looks.
namespace {
const QColor ground(40, 60, 160);
// Eight pixels a point: the pens and dashes grow alike.
constexpr int scale = 8;
// The overlay over the blue ground, eight pixels a point.
QImage drawn(const BrushCursorOverlay &overlay)
{
QImage image(100 * scale, 100 * scale, QImage::Format_RGB32);
image.fill(ground);
QPainter painter(&image);
painter.scale(scale, scale);
overlay.draw(painter);
painter.end();
return image;
}
QColor point(const QImage &image, double x, double y)
{
return image.pixelColor(int(x * scale), int(y * scale));
}
// A solid green preview; a tip opaque within ten points.
QImage green()
{
QImage image(40, 40, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(0, 255, 0));
return image;
}
QImage tip()
{
QImage image(40, 40, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::transparent);
QPainter painter(&image);
painter.setBrush(Qt::white);
painter.setPen(Qt::NoPen);
painter.drawEllipse(QPointF(20, 20), 10, 10);
return image;
}
// A 60-point circle at the middle of a blue square.
QImage drawn(std::optional<double> hardness)
{
BrushCursorOverlay overlay;
overlay.update(QPointF(50, 50), 60, hardness);
QImage image(100 * scale, 100 * scale, QImage::Format_RGB32);
image.fill(ground);
QPainter painter(&image);
painter.scale(scale, scale);
overlay.draw(painter);
painter.end();
return image;
}
bool white(QColor colour)
{
return colour.red() > 200 && colour.green() > 200 && colour.blue() > 200;
}
bool black(QColor colour)
{
return colour.red() < 40 && colour.green() < 40 && colour.blue() < 40;
}
// The pixel a given number of points from the middle.
QColor at(const QImage &image, double radius, double angle)
{
return image.pixelColor(int(50 * scale + radius * scale * std::cos(angle)), int(50 * scale + radius * scale * std::sin(angle)));
}
// How many of 720 angles pass `test` at a radius.
int around(const QImage &image, double radius, const std::function<bool(QColor)> &test)
{
int found = 0;
for (int step = 0; step < 720; ++step)
found += test(at(image, radius, step * std::numbers::pi / 360));
return found;
}
}
class BrushCursorOverlayTests : public QObject {
Q_OBJECT
private slots:
void updatesRepaintTheOldAndNewRings();
void theRingIsBlackOverWhiteAndAntialiased();
void theHardnessRingDashesInStepInsideTheCircle();
void theSourceMarkerRepaintsAroundItselfWhiteUnderBlack();
void thePreviewFillsTheCircleCutToTheTipAtItsOpacity();
void imagesCompareByIdentity();
void theTipCutsAtTheDrawingsResolution();
};
void BrushCursorOverlayTests::updatesRepaintTheOldAndNewRings()
{
BrushCursorOverlay overlay;
// Three points round the circle hold the white stroke.
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt), QRegion());
// Hardness alone repaints the same circle.
QCOMPARE(overlay.update(QPointF(50, 50), 20, 0.5), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.hardness(), std::optional(0.5));
// A move repaints where it was and where it went.
QCOMPARE(overlay.update(QPointF(90, 50), 20, 0.5), QRegion(QRect(37, 37, 26, 26)) + QRegion(QRect(77, 37, 26, 26)));
QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt), QRegion(QRect(77, 37, 26, 26)));
QVERIFY(!overlay.circle().has_value());
}
void BrushCursorOverlayTests::theRingIsBlackOverWhiteAndAntialiased()
{
const QImage image = drawn(std::nullopt);
// Black a point wide on the circle, white round it.
QCOMPARE(around(image, 30, black), 720);
QCOMPARE(around(image, 30.9, white), 720);
QCOMPARE(around(image, 29.1, white), 720);
// Nothing fills it, and nothing lies past the white.
for (const double radius : {0.0, 10.0, 28.5, 31.5, 40.0})
QCOMPARE(around(image, radius, [](QColor colour) { return colour == ground; }), 720);
// The white's outer edge blends into the ground.
QVERIFY(around(image, 31.25, [](QColor colour) { return colour != ground && !white(colour); }) > 300);
}
void BrushCursorOverlayTests::theHardnessRingDashesInStepInsideTheCircle()
{
// A quarter hard: the ring at three eighths across.
const QImage image = drawn(0.25);
QCOMPARE(around(image, 22.5, [](QColor colour) { return colour == ground; }), 720);
const int dashes = around(image, 7.5, black);
// Four points on, three off: black and white halo together.
int apart = 0, turns = 0;
for (int step = 0; step < 720; ++step) {
const double angle = step * std::numbers::pi / 360, next = (step + 1) * std::numbers::pi / 360;
apart += black(at(image, 7.5, angle)) != white(at(image, 8.4, angle));
turns += white(at(image, 8.4, angle)) != white(at(image, 8.4, next));
}
QVERIFY2(dashes > 300 && dashes < 520 && apart < 30 && turns >= 12, qPrintable(QString("%1 %2 %3").arg(dashes).arg(apart).arg(turns)));
// At no hardness there is no second ring.
const QImage soft = drawn(0.0);
for (const double radius : {0.0, 7.5, 15.0, 28.5})
QCOMPARE(around(soft, radius, [](QColor colour) { return colour == ground; }), 720);
}
void BrushCursorOverlayTests::theSourceMarkerRepaintsAroundItselfWhiteUnderBlack()
{
BrushCursorOverlay overlay;
// Seven-point arms, three points of white round them.
QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(30, 40)), QRegion(QRect(20, 30, 20, 20)));
QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(30, 40)), QRegion());
QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(60, 40)), QRegion(QRect(20, 30, 20, 20)) + QRegion(QRect(50, 30, 20, 20)));
QCOMPARE(overlay.marker(), std::optional(QPointF(60, 40)));
// The marker draws without a circle.
const QImage image = drawn(overlay);
QVERIFY(black(point(image, 60, 40)) && black(point(image, 66, 40.1)) && black(point(image, 60.1, 34)));
QVERIFY(white(point(image, 63, 41)) && white(point(image, 67.9, 40.1)) && white(point(image, 59, 46)));
QCOMPARE(point(image, 63, 42), ground);
QCOMPARE(point(image, 69, 40.1), ground);
QCOMPARE(point(image, 63, 43), ground);
QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, std::nullopt), QRegion(QRect(50, 30, 20, 20)));
}
void BrushCursorOverlayTests::thePreviewFillsTheCircleCutToTheTipAtItsOpacity()
{
BrushCursorOverlay overlay;
overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, green(), 0.5, tip());
const QImage cut = drawn(overlay);
// Half green over the ground, only where a click paints.
const QColor middle = point(cut, 50, 50);
QVERIFY2(std::abs(middle.red() - 20) <= 2 && std::abs(middle.green() - 158) <= 2 && std::abs(middle.blue() - 80) <= 2,
qPrintable(middle.name()));
QCOMPARE(point(cut, 65, 50), ground);
QCOMPARE(point(cut, 50, 38), ground);
// A new tip over the same preview cuts it anew.
QImage small = tip();
small.fill(Qt::transparent);
QPainter(&small).fillRect(QRect(18, 18, 4, 4), Qt::white);
overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, overlay.preview(), 0.5, small);
QCOMPARE(point(drawn(overlay), 50, 45), ground);
// Without a tip the whole circle shows it, nothing past.
overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, green(), 1, QImage());
const QImage whole = drawn(overlay);
QCOMPARE(point(whole, 65, 50), QColor(0, 255, 0));
QCOMPARE(point(whole, 50, 32), QColor(0, 255, 0));
QCOMPARE(point(whole, 50, 27), ground);
QCOMPARE(point(whole, 66, 34), ground);
}
void BrushCursorOverlayTests::imagesCompareByIdentity()
{
BrushCursorOverlay overlay;
const QImage preview = green(), cut = tip();
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview, 1, cut), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview, 1, cut), QRegion());
// Equal pixels in another image differ, as Swift's `!==` says.
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview.copy(), 1, cut), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 1, cut.copy()), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 0.4, overlay.preview()), QRegion(QRect(37, 37, 26, 26)));
QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 0.6, overlay.preview()), QRegion(QRect(37, 37, 26, 26)));
}
void BrushCursorOverlayTests::theTipCutsAtTheDrawingsResolution()
{
// A capped preview enlarged fourfold; a tip of one-pixel columns.
QImage preview(100, 100, QImage::Format_RGBA8888_Premultiplied);
preview.fill(QColor(0, 255, 0));
QImage columns(400, 400, QImage::Format_RGBA8888_Premultiplied);
columns.fill(Qt::transparent);
QPainter stripes(&columns);
for (int x = 0; x < 400; x += 2)
stripes.fillRect(QRect(x, 0, 1, 400), Qt::white);
stripes.end();
const auto draw = [](const BrushCursorOverlay &overlay) {
QImage image(500, 500, QImage::Format_RGB32);
image.fill(ground);
QPainter painter(&image);
overlay.draw(painter);
return image;
};
BrushCursorOverlay overlay;
overlay.update(QPointF(250, 250), 400, std::nullopt, std::nullopt, preview, 1, columns);
const QImage cut = draw(overlay);
// Each column keeps or drops the preview, as clicks would.
QCOMPARE(cut.pixelColor(250, 250), QColor(0, 255, 0));
QCOMPARE(cut.pixelColor(251, 250), ground);
QCOMPARE(cut.pixelColor(120, 300), QColor(0, 255, 0));
QCOMPARE(cut.pixelColor(121, 300), ground);
// The enlarged preview is smoothed, as Swift's medium quality.
QImage halves = preview.copy();
QPainter(&halves).fillRect(QRect(0, 0, 50, 100), QColor(255, 0, 0));
overlay.update(QPointF(250, 250), 400, std::nullopt, std::nullopt, halves, 1, QImage());
const QColor seam = draw(overlay).pixelColor(250, 250);
QVERIFY2(seam.red() > 40 && seam.green() > 40, qPrintable(seam.name()));
}
QTEST_GUILESS_MAIN(BrushCursorOverlayTests)
#include "BrushCursorOverlayTests.moc"