Merge branch 'main' into port-d
This commit is contained in:
commit
7c6265af77
141 files changed
+5822
-682
No files matched your search
@@ -0,0 +1,124 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/HueSaturation.h"
|
||||
#include "Document/Levels.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <QtTest>
|
||||
#include <mutex>
|
||||
extern "C" {
|
||||
#include "LevelsPixels.h"
|
||||
}
|
||||
|
||||
// Compositor 1.4: Levels and Hue/Saturation in bands across the cores.
|
||||
namespace {
|
||||
// Premultiplied noise, some of it partly clear.
|
||||
QImage noise(int width, int height)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
QRandomGenerator random(7);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
uchar *row = image.scanLine(y);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const uchar alpha = uchar(random.bounded(256));
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
row[x * 4 + channel] = uchar(random.bounded(alpha + 1));
|
||||
row[x * 4 + 3] = alpha;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
std::vector<uchar> bytes(const QImage &image)
|
||||
{
|
||||
std::vector<uchar> result;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class AdjustmentBandsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void bandsCoverEveryPixelOnce();
|
||||
void aCopyKeepsTheBytes();
|
||||
void levelsInBandsMatchesOnePass();
|
||||
void hueSaturationInBandsMatchesOnePass();
|
||||
void cachedTablesFollowTheirSettings();
|
||||
};
|
||||
|
||||
void AdjustmentBandsTests::bandsCoverEveryPixelOnce()
|
||||
{
|
||||
for (const qsizetype count : {qsizetype(10), qsizetype(249'999), qsizetype(250'007), qsizetype(1'000'003)}) {
|
||||
std::vector<int> marks(size_t(count), 0);
|
||||
std::mutex lock;
|
||||
int calls = 0;
|
||||
BrushRaster::inBands(count, [&](qsizetype start, qsizetype length) {
|
||||
QVERIFY(length > 0);
|
||||
for (qsizetype index = start; index < start + length; ++index)
|
||||
++marks[size_t(index)];
|
||||
const std::lock_guard<std::mutex> held(lock);
|
||||
++calls;
|
||||
});
|
||||
QVERIFY(std::all_of(marks.begin(), marks.end(), [](int mark) { return mark == 1; }));
|
||||
// Small counts run as one band.
|
||||
QCOMPARE(calls > 1, count >= 250'000);
|
||||
}
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::aCopyKeepsTheBytes()
|
||||
{
|
||||
const QImage source = noise(37, 11);
|
||||
const QImage copied = BrushRaster::copy(source);
|
||||
QCOMPARE(copied.format(), QImage::Format_RGBA8888_Premultiplied);
|
||||
QCOMPARE(bytes(copied), bytes(source));
|
||||
QVERIFY(copied.constBits() != source.constBits());
|
||||
// Another layout is drawn into one.
|
||||
QImage argb(3, 1, QImage::Format_ARGB32);
|
||||
argb.fill(qRgba(255, 0, 0, 255));
|
||||
QCOMPARE(bytes(BrushRaster::copy(argb)), (std::vector<uchar>{255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255}));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::levelsInBandsMatchesOnePass()
|
||||
{
|
||||
const QImage source = noise(701, 401);
|
||||
LevelsSettings settings;
|
||||
settings.ranges[0] = LevelRange{.black = 20, .gamma = 1.7, .white = 230, .outputBlack = 10, .outputWhite = 240};
|
||||
std::array<float, 3 * 256> tables;
|
||||
for (size_t channel = 0; channel < 3; ++channel) {
|
||||
for (size_t value = 0; value <= 255; ++value)
|
||||
tables[channel * 256 + value] = float(settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
|
||||
}
|
||||
QImage reference = source.copy();
|
||||
levels_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), tables.data());
|
||||
const QImage banded = LevelsFilter::run(LevelsJob{source, settings, std::nullopt, QTransform()});
|
||||
QCOMPARE(bytes(banded), bytes(reference));
|
||||
QVERIFY(bytes(banded) != bytes(source));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::hueSaturationInBandsMatchesOnePass()
|
||||
{
|
||||
const QImage source = noise(701, 401);
|
||||
const HueSaturationSettings settings(120, 40, -10);
|
||||
const std::vector<float> cube = HueSaturationFilter::cube(settings);
|
||||
QImage reference = source.copy();
|
||||
cube_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), cube.data(), HueSaturationFilter::dimension);
|
||||
const QImage banded = HueSaturationFilter::run(HueSaturationJob{source, settings, std::nullopt, QTransform(), false}).image;
|
||||
QCOMPARE(bytes(banded), bytes(reference));
|
||||
QVERIFY(bytes(banded) != bytes(source));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::cachedTablesFollowTheirSettings()
|
||||
{
|
||||
// More settings than the cache holds, asked twice over.
|
||||
std::vector<std::vector<float>> first;
|
||||
for (int hue = 0; hue < 10; ++hue)
|
||||
first.push_back(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)));
|
||||
for (int round = 0; round < 2; ++round) {
|
||||
for (int hue = 9; hue >= 0; --hue)
|
||||
QVERIFY(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)) == first[size_t(hue)]);
|
||||
}
|
||||
QVERIFY(first[0] != first[1]);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(AdjustmentBandsTests)
|
||||
#include "AdjustmentBandsTests.moc"
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "CanvasContractFixtures.h"
|
||||
|
||||
// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments.
|
||||
namespace {
|
||||
// A canvas over a photo and a smaller, translucent layer.
|
||||
struct Scene : ContractScene {
|
||||
Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight)
|
||||
{
|
||||
const QImage photo = noise(width, height, 3);
|
||||
session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
|
||||
const QImage top = noise(width / 2, height / 2, 5, 160);
|
||||
session.insert(ImportedImage(top, top, QStringLiteral("Top")));
|
||||
}
|
||||
// An adjustment layer of this kind on top, at 80%.
|
||||
QUuid adjust(const LayerAdjustment &adjustment)
|
||||
{
|
||||
session.addAdjustment(adjustment.kind);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
session.updateAdjustment(id, adjustment);
|
||||
session.setLayerOpacity(0.8);
|
||||
return id;
|
||||
}
|
||||
};
|
||||
|
||||
LayerAdjustment visible(AdjustmentKind kind)
|
||||
{
|
||||
LayerAdjustment adjustment{kind};
|
||||
switch (kind) {
|
||||
case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break;
|
||||
case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break;
|
||||
case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break;
|
||||
case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break;
|
||||
case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break;
|
||||
case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break;
|
||||
case AdjustmentKind::motionBlur:
|
||||
adjustment.setResolvedMotionDistance(30);
|
||||
adjustment.setResolvedMotionAngle(30);
|
||||
break;
|
||||
case AdjustmentKind::addNoise:
|
||||
adjustment.setResolvedNoiseAmount(25);
|
||||
adjustment.setResolvedNoiseSeed(7);
|
||||
break;
|
||||
case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break;
|
||||
default: break;
|
||||
}
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AdjustmentCanvasTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void everyAdjustmentShowsAsTheExport_data();
|
||||
void everyAdjustmentShowsAsTheExport();
|
||||
void patternsStayWithTheDocument_data();
|
||||
void patternsStayWithTheDocument();
|
||||
};
|
||||
|
||||
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
}
|
||||
|
||||
// Swift's matchesEveryAdjustment: a mask, 80%, one to one.
|
||||
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport()
|
||||
{
|
||||
QFETCH(AdjustmentKind, kind);
|
||||
Scene scene(60, 50, 60, 50);
|
||||
const QImage before = scene.exported();
|
||||
const QUuid id = scene.adjust(visible(kind));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); });
|
||||
scene.session.zoom(1);
|
||||
QVERIFY(scene.exported() != before);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
void AdjustmentCanvasTests::patternsStayWithTheDocument_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
QTest::newRow("addNoise") << AdjustmentKind::addNoise;
|
||||
QTest::newRow("grain") << AdjustmentKind::grain;
|
||||
}
|
||||
|
||||
// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed.
|
||||
void AdjustmentCanvasTests::patternsStayWithTheDocument()
|
||||
{
|
||||
QFETCH(AdjustmentKind, kind);
|
||||
Scene scene(60, 50, 140, 120);
|
||||
scene.adjust(visible(kind));
|
||||
// One to one, and the crisp path's document pixels.
|
||||
for (const double zoom : {1.0, 2.0}) {
|
||||
scene.session.zoom(zoom);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport())));
|
||||
scene.session.viewport.translate(QSizeF(3, -2));
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport())));
|
||||
}
|
||||
// Reduced: a whole-pixel pan moves the pattern with the image.
|
||||
scene.session.zoom(0.8);
|
||||
const QImage before = scene.shot();
|
||||
const QPoint corner = scene.origin().toPoint();
|
||||
scene.session.viewport.translate(QSizeF(5, 3));
|
||||
const QImage after = scene.shot();
|
||||
QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3));
|
||||
int worst = 0;
|
||||
for (int y = corner.y() + 2; y < corner.y() + 36; ++y) {
|
||||
for (int x = corner.x() + 2; x < corner.x() + 44; ++x) {
|
||||
const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3);
|
||||
worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))});
|
||||
}
|
||||
}
|
||||
QVERIFY2(worst <= 1, qPrintable(QString::number(worst)));
|
||||
// A partial repaint shows the whole's pattern, resampled alike.
|
||||
const QRect part(corner + QPoint(18, 12), QSize(20, 15));
|
||||
const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2);
|
||||
}
|
||||
|
||||
QTEST_MAIN(AdjustmentCanvasTests)
|
||||
#include "AdjustmentCanvasTests.moc"
|
||||
@@ -107,12 +107,31 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn()
|
||||
{
|
||||
QImage surface = BrushRaster::context(8, 8, false);
|
||||
QPainter painter(&surface);
|
||||
// The budget counts device pixels: 64 here, not 16 units.
|
||||
// 64 device pixels; past that 16 units; past that, none.
|
||||
painter.scale(2, 2);
|
||||
bool called = false;
|
||||
QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)");
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 15);
|
||||
QVERIFY(!called);
|
||||
QSize allocated;
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) {
|
||||
allocated = QSize(target.device()->width(), target.device()->height());
|
||||
target.fillRect(QRectF(0, 0, 2, 4), Qt::blue);
|
||||
target.fillRect(QRectF(2, 0, 2, 4), Qt::green);
|
||||
}, 0, 63);
|
||||
QCOMPARE(allocated, QSize(4, 4));
|
||||
// A device pixel at a quarter: one pixel, not none.
|
||||
painter.save();
|
||||
painter.scale(2, 2);
|
||||
painter.setClipRect(QRectF(0, 0, 0.25, 1));
|
||||
allocated = QSize();
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) { allocated = QSize(target.device()->width(), target.device()->height()); }, 0, 3);
|
||||
painter.restore();
|
||||
QCOMPARE(allocated, QSize(1, 1));
|
||||
// Enlarged smoothly: the seam between blue and green blends.
|
||||
QCOMPARE(surface.pixel(0, 0), qRgba(0, 0, 255, 255));
|
||||
QCOMPARE(surface.pixel(7, 0), qRgba(0, 255, 0, 255));
|
||||
QVERIFY(qBlue(surface.pixel(4, 0)) > 0 && qBlue(surface.pixel(4, 0)) < 255);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) {
|
||||
called = true;
|
||||
target.fillRect(QRectF(0, 0, 4, 4), Qt::red);
|
||||
|
||||
@@ -70,11 +70,10 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source)
|
||||
case LayerBlendMode::multiply: result[channel] = b * s; break;
|
||||
case LayerBlendMode::screen: result[channel] = b + s - b * s; break;
|
||||
case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break;
|
||||
case LayerBlendMode::softLight: {
|
||||
const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b);
|
||||
result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b);
|
||||
// Photoshop's, as Core Image computes it (Compositor 1.4).
|
||||
case LayerBlendMode::softLight:
|
||||
result[channel] = s <= 0.5 ? 2 * b * s + b * b * (1 - 2 * s) : 2 * b * (1 - s) + std::sqrt(b) * (2 * s - 1);
|
||||
break;
|
||||
}
|
||||
case LayerBlendMode::darken: result[channel] = std::min(b, s); break;
|
||||
case LayerBlendMode::lighten: result[channel] = std::max(b, s); break;
|
||||
case LayerBlendMode::difference: result[channel] = std::abs(b - s); break;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include <QPainter>
|
||||
#include <QRadialGradient>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's blending: stacks, adjustments in a mode, Soft Light.
|
||||
namespace {
|
||||
ImportedImage filled(int width, int height, QColor colour, const QString &name)
|
||||
{
|
||||
const QImage image = solid(width, height, colour.rgba());
|
||||
return ImportedImage(image, image, name);
|
||||
}
|
||||
|
||||
// A canvas at 1:1 over the document.
|
||||
struct Scene {
|
||||
EditorSession session;
|
||||
CanvasView canvas{session};
|
||||
Scene(int width, int height)
|
||||
{
|
||||
session.createDocument(width, height);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(width + 40, height + 40);
|
||||
session.viewport.resize(QSizeF(width + 40, height + 40), 1, QSizeF(width, height));
|
||||
session.zoom(1);
|
||||
}
|
||||
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
|
||||
// Copy Merged of the whole canvas.
|
||||
QImage merged()
|
||||
{
|
||||
session.selectAll();
|
||||
const QImage image = session.renderMergedPixels().value().image.convertToFormat(QImage::Format_ARGB32);
|
||||
session.deselect();
|
||||
return image;
|
||||
}
|
||||
// The worst channel gap to the export, inside the border.
|
||||
int canvasGap()
|
||||
{
|
||||
const QImage expected = exported(), shot = canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
const QPointF origin = session.viewport.viewPoint(QPointF(0, 0), session.document().value().size());
|
||||
int worst = 0;
|
||||
for (int y = 1; y < expected.height() - 1; ++y) {
|
||||
for (int x = 1; x < expected.width() - 1; ++x) {
|
||||
const QRgb wanted = expected.pixel(x, y), shown = shot.pixel(int(origin.x() + x + 0.5), int(origin.y() + y + 0.5));
|
||||
worst = std::max({worst, std::abs(qRed(wanted) - qRed(shown)), std::abs(qGreen(wanted) - qGreen(shown)),
|
||||
std::abs(qBlue(wanted) - qBlue(shown))});
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
void mode(LayerBlendMode mode)
|
||||
{
|
||||
session.setLayerBlendMode(mode);
|
||||
}
|
||||
};
|
||||
|
||||
std::array<int, 3> rgb(QRgb pixel)
|
||||
{
|
||||
return {qRed(pixel), qGreen(pixel), qBlue(pixel)};
|
||||
}
|
||||
|
||||
bool near(QRgb pixel, std::array<int, 3> expected, int tolerance)
|
||||
{
|
||||
const std::array<int, 3> actual = rgb(pixel);
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
if (std::abs(actual[size_t(channel)] - expected[size_t(channel)]) > tolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString text(QRgb pixel)
|
||||
{
|
||||
return QStringLiteral("(%1, %2, %3)").arg(qRed(pixel)).arg(qGreen(pixel)).arg(qBlue(pixel));
|
||||
}
|
||||
}
|
||||
|
||||
class BlendingTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void clippingStacksBlendInTheirBasesMode_data();
|
||||
void clippingStacksBlendInTheirBasesMode();
|
||||
void adjustmentsBlendInAnyMode_data();
|
||||
void adjustmentsBlendInAnyMode();
|
||||
void softLightMatchesPhotoshop();
|
||||
void softEdgedLayersBlendTheSame_data();
|
||||
void softEdgedLayersBlendTheSame();
|
||||
};
|
||||
|
||||
void BlendingTests::clippingStacksBlendInTheirBasesMode_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::newRow("linearDodge") << LayerBlendMode::linearDodge;
|
||||
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
|
||||
QTest::newRow("multiply") << LayerBlendMode::multiply;
|
||||
}
|
||||
|
||||
// Swift's: the stack takes its base's mode wherever drawn.
|
||||
void BlendingTests::clippingStacksBlendInTheirBasesMode()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
Scene scene(100, 100);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(100, 100, QColor::fromRgbF(0.4f, 0.2f, 0.1f), QStringLiteral("Base")));
|
||||
session.insert(filled(100, 100, QColor::fromRgbF(0.3f, 0.3f, 0.3f), QStringLiteral("Blended")));
|
||||
const QUuid blended = session.activeLayerID().value();
|
||||
scene.mode(mode);
|
||||
session.insert(filled(50, 50, QColor::fromRgbF(0.2f, 0.05f, 0), QStringLiteral("Clipped")), QPointF(25, 25));
|
||||
QVERIFY(session.linkMask(blended, session.activeLayerID().value()));
|
||||
const QImage exported = scene.exported(), merged = scene.merged();
|
||||
// Under the clipped layer, and the stack's base alone.
|
||||
for (const QPoint point : {QPoint(20, 20), QPoint(80, 80)})
|
||||
QCOMPARE(text(merged.pixel(point)), text(exported.pixel(point)));
|
||||
if (mode == LayerBlendMode::linearDodge) {
|
||||
QVERIFY2(near(exported.pixel(20, 20), {153, 64, 26}, 1), qPrintable(text(exported.pixel(20, 20))));
|
||||
QVERIFY2(near(exported.pixel(80, 80), {179, 128, 102}, 1), qPrintable(text(exported.pixel(80, 80))));
|
||||
}
|
||||
QVERIFY(text(exported.pixel(20, 20)) != text(exported.pixel(80, 80)));
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
}
|
||||
|
||||
void BlendingTests::adjustmentsBlendInAnyMode_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::addColumn<bool>("clipped");
|
||||
QTest::addColumn<QList<int>>("expected");
|
||||
// (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4).
|
||||
QTest::newRow("difference") << LayerBlendMode::difference << false << QList<int>{153, 51, 51};
|
||||
QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList<int>{204, 153, 102};
|
||||
QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList<int>{128, 128, 128};
|
||||
QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList<int>{0, 0, 51};
|
||||
}
|
||||
|
||||
// Blended at full coverage, alpha restored, then mixed by opacity.
|
||||
void BlendingTests::adjustmentsBlendInAnyMode()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
QFETCH(bool, clipped);
|
||||
QFETCH(QList<int>, expected);
|
||||
Scene scene(40, 30);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(40, 30, QColor::fromRgbF(0.2f, 0.4f, 0.6f), QStringLiteral("Photo")));
|
||||
const QUuid photo = session.activeLayerID().value();
|
||||
session.addAdjustment(AdjustmentKind::invert);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid adjustment = session.activeLayerID().value();
|
||||
scene.mode(mode);
|
||||
if (clipped)
|
||||
QVERIFY(session.linkMask(photo, adjustment));
|
||||
const QImage exported = scene.exported();
|
||||
const QRgb shown = exported.pixel(10, 10);
|
||||
QVERIFY2(near(shown, {expected[0], expected[1], expected[2]}, 1), qPrintable(text(shown)));
|
||||
QCOMPARE(qAlpha(shown), 255);
|
||||
QCOMPARE(text(scene.merged().pixel(10, 10)), text(shown));
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
// At half opacity the result mixes halfway back.
|
||||
session.selectLayer(adjustment);
|
||||
session.setLayerOpacity(0.5);
|
||||
const QRgb half = scene.exported().pixel(10, 10);
|
||||
QVERIFY2(near(half, {(expected[0] + 51) / 2, (expected[1] + 102) / 2, (expected[2] + 153) / 2}, 1), qPrintable(text(half)));
|
||||
}
|
||||
|
||||
// Photoshop's: 2·b·(1 − s) + √b·(2s − 1) above half.
|
||||
void BlendingTests::softLightMatchesPhotoshop()
|
||||
{
|
||||
const auto blended = [](double backdrop, double source) {
|
||||
Scene scene(10, 10);
|
||||
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(backdrop), float(backdrop), float(backdrop)), QStringLiteral("Base")));
|
||||
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(source), float(source), float(source)), QStringLiteral("Soft")));
|
||||
scene.mode(LayerBlendMode::softLight);
|
||||
const int exported = qRed(scene.exported().pixel(5, 5));
|
||||
if (qRed(scene.merged().pixel(5, 5)) != exported || scene.canvasGap() > 1)
|
||||
return -1;
|
||||
return exported;
|
||||
};
|
||||
// 2·0.5·0.1 + √0.5·0.8 = 0.666: 170.
|
||||
QCOMPARE(blended(0.5, 0.9), 170);
|
||||
// Dark: √(5/255) is 0.140, so 36; the PDF gives 19.
|
||||
QCOMPARE(blended(5 / 255.0, 1), 36);
|
||||
// Below half, 2bs + b²(1 − 2s): 0.1375 here.
|
||||
QCOMPARE(blended(0.25, 0.2), 35);
|
||||
}
|
||||
|
||||
void BlendingTests::softEdgedLayersBlendTheSame_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::newRow("softLight") << LayerBlendMode::softLight;
|
||||
QTest::newRow("overlay") << LayerBlendMode::overlay;
|
||||
QTest::newRow("multiply") << LayerBlendMode::multiply;
|
||||
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
|
||||
}
|
||||
|
||||
// A soft brush's layer, mostly partly clear, alike everywhere.
|
||||
void BlendingTests::softEdgedLayersBlendTheSame()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
Scene scene(60, 50);
|
||||
const QImage photo = noise(60, 50, 4);
|
||||
scene.session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
|
||||
QImage soft(28, 18, QImage::Format_RGBA8888_Premultiplied);
|
||||
soft.fill(Qt::transparent);
|
||||
{
|
||||
QPainter painter(&soft);
|
||||
QRadialGradient gradient(QPointF(14, 9), 11);
|
||||
gradient.setColorAt(0.3, QColor::fromRgbF(1, 0.05f, 0, 1));
|
||||
gradient.setColorAt(1, QColor::fromRgbF(1, 0.05f, 0, 0));
|
||||
painter.fillRect(soft.rect(), gradient);
|
||||
}
|
||||
scene.session.insert(ImportedImage(soft, soft, QStringLiteral("Paint")), QPointF(30, 23));
|
||||
scene.mode(mode);
|
||||
const QImage exported = scene.exported();
|
||||
// Partly clear pixels blend, as do the opaque ones.
|
||||
QVERIFY(exported.pixel(30, 23) != photo.pixel(30, 23) && exported.pixel(23, 23) != photo.pixel(23, 23));
|
||||
QCOMPARE(scene.merged(), exported);
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
}
|
||||
|
||||
QTEST_MAIN(BlendingTests)
|
||||
#include "BlendingTests.moc"
|
||||
@@ -295,6 +295,14 @@ void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions()
|
||||
QImage expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40);
|
||||
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected);
|
||||
// Each axis by its own scale (Compositor 1.4).
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 10);
|
||||
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 16)), expected);
|
||||
// A region under a unit wide counts as one.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 8, 16);
|
||||
QCOMPARE(noise.apply(gray, QRectF(1, 2, 0.5, 0.5)), expected);
|
||||
// A negative origin floors, as Swift rounds down.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21);
|
||||
|
||||
@@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void curvesAreRepairedIntoOrder();
|
||||
void theParametricCurveLiftsItsRegion();
|
||||
void aToneFindsItsRegion();
|
||||
void aNudgeMovesTheFirstNearestPointWithinRange();
|
||||
void splitsAndAmountsStayInTheirRanges();
|
||||
void mixerFamiliesOverlapRoundTheWheel();
|
||||
@@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder()
|
||||
QCOMPARE(table[100], float(100 / 255.0));
|
||||
}
|
||||
|
||||
void CameraRawColorTests::theParametricCurveLiftsItsRegion()
|
||||
void CameraRawColorTests::aToneFindsItsRegion()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.shadows = 100;
|
||||
// The region's middle takes the whole 22%.
|
||||
QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12);
|
||||
QCOMPARE(curve.parametric(0.375), 0.375);
|
||||
curve.highlights = -100;
|
||||
curve.lightSplit = 100;
|
||||
// A region of no width still takes a fiftieth's span.
|
||||
QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12);
|
||||
// Region picks the amount a tone moves.
|
||||
CameraRawCurveSettings regions;
|
||||
regions.region(0.1) = 1;
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "UI/CameraRawColorControls.h"
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.4.5: the Camera Raw curve graph's one drag.
|
||||
namespace {
|
||||
struct Graph {
|
||||
EditorSession session;
|
||||
std::unique_ptr<CameraRawCurveControls> controls;
|
||||
QWidget *graph = nullptr;
|
||||
explicit Graph(bool point = false)
|
||||
{
|
||||
session.createDocument(8, 8);
|
||||
QImage image = BrushRaster::context(8, 8, false);
|
||||
image.fill(QColor(200, 120, 60));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
|
||||
session.beginFilter(FilterKind::cameraRaw);
|
||||
controls = std::make_unique<CameraRawCurveControls>(session);
|
||||
controls->resize(374, 600);
|
||||
controls->show();
|
||||
graph = controls->findChild<QWidget *>(QStringLiteral("cameraRawCurveGraph"));
|
||||
if (point)
|
||||
controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
|
||||
}
|
||||
const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; }
|
||||
void set(const std::function<void(CameraRawCurveSettings &)> &change)
|
||||
{
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
change(settings.cameraRaw.curve);
|
||||
session.updateFilter(settings, true);
|
||||
}
|
||||
// A curve position in the graph's own points.
|
||||
QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); }
|
||||
void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); }
|
||||
void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const
|
||||
{
|
||||
QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier);
|
||||
QApplication::sendEvent(graph, &event);
|
||||
}
|
||||
void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); }
|
||||
QLabel &readout() const { return *controls->findChild<QLabel *>(QStringLiteral("curveSelectedPoint")); }
|
||||
QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); }
|
||||
};
|
||||
}
|
||||
|
||||
class CameraRawCurveGraphTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aRegionDragLiftsOrLowersThePressedRegion();
|
||||
void theRegionIsPickedOnceByItsTone();
|
||||
void dividersMoveInTheBottomStripAndStayOrdered();
|
||||
void aPointIsPickedByDistanceAndKept();
|
||||
void aPressElsewhereAddsAndDragsAPoint();
|
||||
void addingIsRefusedNearAnEdgeAPointOrSixteen();
|
||||
void theChosenPointIsLitAndReadOut();
|
||||
void aNewChannelOrPresetDropsTheChoice();
|
||||
void aDoubleClickRemovesAnInnerPointAndTheChoice();
|
||||
void onlyTheLeftButtonDragsAndAPressStartsAfresh();
|
||||
void theGraphSaysWhatADragDoes();
|
||||
};
|
||||
|
||||
void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion()
|
||||
{
|
||||
Graph graph;
|
||||
const int h = graph.graph->height();
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; });
|
||||
// Up lifts: a quarter height adds 50.
|
||||
graph.press(QPoint(int(graph.graph->width() * 0.6), 100));
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4));
|
||||
QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200));
|
||||
// Down lowers, clamped at −100, rounded to whole.
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h));
|
||||
QCOMPARE(graph.curve().lights, -100.0);
|
||||
graph.move(QPoint(10, 100 + 3));
|
||||
QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200));
|
||||
graph.release(QPoint(10, 103));
|
||||
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0);
|
||||
// Far up, +100 at most.
|
||||
graph.press(QPoint(int(graph.graph->width() * 0.9), 120));
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h));
|
||||
QCOMPARE(graph.curve().highlights, 100.0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone()
|
||||
{
|
||||
Graph graph;
|
||||
const int w = graph.graph->width();
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; });
|
||||
// A tone on a divider joins the region above.
|
||||
const std::pair<double, double CameraRawCurveSettings::*> regions[] = {
|
||||
{0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}};
|
||||
for (const auto &[tone, key] : regions) {
|
||||
const CameraRawCurveSettings before = graph.curve();
|
||||
graph.press(QPoint(int(std::lround(tone * w)), 60));
|
||||
graph.move(QPoint(int(std::lround(tone * w)), 50));
|
||||
graph.release(QPoint(int(std::lround(tone * w)), 50));
|
||||
CameraRawCurveSettings expected = before;
|
||||
expected.*key = std::round(10.0 / graph.graph->height() * 200);
|
||||
QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone)));
|
||||
}
|
||||
// At tone 50 exactly, each divider hands it upward.
|
||||
QCOMPARE(w % 2, 0);
|
||||
const std::tuple<std::array<double, 3>, double CameraRawCurveSettings::*> onDividers[] = {
|
||||
{{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}};
|
||||
for (const auto &[splits, key] : onDividers) {
|
||||
graph.set([&splits](CameraRawCurveSettings &curve) {
|
||||
curve = CameraRawCurveSettings();
|
||||
curve.shadowSplit = splits[0];
|
||||
curve.darkSplit = splits[1];
|
||||
curve.lightSplit = splits[2];
|
||||
});
|
||||
graph.press(QPoint(w / 2, 60));
|
||||
graph.move(QPoint(w / 2, 50));
|
||||
graph.release(QPoint(w / 2, 50));
|
||||
QVERIFY(graph.curve().*key > 0);
|
||||
}
|
||||
// Kept through the drag: across another region, the same one.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; });
|
||||
graph.press(QPoint(int(0.1 * w), 60));
|
||||
graph.move(QPoint(int(0.9 * w), 40));
|
||||
QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered()
|
||||
{
|
||||
Graph graph;
|
||||
const int w = graph.graph->width(), h = graph.graph->height();
|
||||
// The strip is the bottom 18 points.
|
||||
graph.press(QPoint(int(0.5 * w), h - 18));
|
||||
graph.move(QPoint(int(0.6 * w), h - 30));
|
||||
graph.release(QPoint(int(0.6 * w), h - 30));
|
||||
QCOMPARE(graph.curve().darkSplit, 50.0);
|
||||
QVERIFY(graph.curve().lights > 0);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; });
|
||||
// Each divider, the nearest to the press, between its neighbours.
|
||||
const auto dragged = [&](double from, double to) {
|
||||
graph.press(QPoint(int(std::lround(from * w)), h - 17));
|
||||
graph.move(QPoint(int(std::lround(to * w)), 10));
|
||||
graph.release(QPoint(int(std::lround(to * w)), 10));
|
||||
};
|
||||
dragged(0.3, 0.9);
|
||||
QCOMPARE(graph.curve().shadowSplit, 48.0);
|
||||
// Past 2 and 98 the settings' own limits hold.
|
||||
dragged(0.47, 0.0);
|
||||
QCOMPARE(graph.curve().shadowSplit, 5.0);
|
||||
dragged(0.55, 0.9);
|
||||
QCOMPARE(graph.curve().darkSplit, 73.0);
|
||||
dragged(0.73, 0.01);
|
||||
QCOMPARE(graph.curve().darkSplit, 7.0);
|
||||
dragged(0.74, 0.0);
|
||||
QCOMPARE(graph.curve().lightSplit, 9.0);
|
||||
dragged(0.095, 1.2);
|
||||
QCOMPARE(graph.curve().lightSplit, 98.0);
|
||||
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0);
|
||||
// At tone 50 exactly, between 40 and 60: the first.
|
||||
QCOMPARE(w % 2, 0);
|
||||
graph.set([](CameraRawCurveSettings &curve) {
|
||||
curve.shadowSplit = 40;
|
||||
curve.darkSplit = 60;
|
||||
curve.lightSplit = 80;
|
||||
});
|
||||
graph.press(QPoint(w / 2, h - 5));
|
||||
graph.move(QPoint(int(0.45 * w), h - 5));
|
||||
QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; });
|
||||
// Nearer by distance, not by x: the second.
|
||||
graph.press(graph.at(0.33, 0.48));
|
||||
graph.move(graph.at(0.33, 0.6));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
// Kept as it passes x 0.99, held before the end.
|
||||
graph.move(graph.at(1.2, 0.2));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
// Fix beyond Swift: onto its neighbour it stays, still dragged.
|
||||
graph.move(graph.at(0.1, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.31) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
graph.move(graph.at(0.6, 0.7));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.6) < 0.003 && graph.curve().rgb[3] == (CurvePoint{1, 1}));
|
||||
graph.release(graph.at(0.6, 0.7));
|
||||
// Onto the next point: a hundredth short; both stay.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.3));
|
||||
graph.move(graph.at(0.8, 0.3));
|
||||
graph.release(graph.at(0.8, 0.3));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-6 && graph.curve().rgb[2] == (CurvePoint{0.5, 0.5}));
|
||||
// 0.05 away takes the point; 0.06 away adds one.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.53, 0.54));
|
||||
graph.release(graph.at(0.53, 0.54));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.54, 0.545));
|
||||
graph.release(graph.at(0.54, 0.545));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5}));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.press(graph.at(0.25, 0.6));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01);
|
||||
// The same press drags it on, in order.
|
||||
graph.move(graph.at(0.4, 0.7));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01);
|
||||
graph.release(graph.at(0.4, 0.7));
|
||||
// Added below, sorted in at its x.
|
||||
graph.press(graph.at(0.8, 0.95));
|
||||
graph.move(graph.at(0.7, 1.4));
|
||||
graph.release(graph.at(0.7, 1.4));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1);
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01);
|
||||
// The ends move their output alone.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.move(graph.at(0.6, 0.8));
|
||||
graph.release(graph.at(0.6, 0.8));
|
||||
QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01);
|
||||
graph.press(graph.at(0, 0));
|
||||
graph.move(graph.at(0.3, -0.5));
|
||||
graph.release(graph.at(0.3, -0.5));
|
||||
QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0}));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen()
|
||||
{
|
||||
Graph graph(true);
|
||||
// Too near an end's x, or a point's.
|
||||
const int w = graph.graph->width();
|
||||
graph.press(QPoint(2, 20));
|
||||
graph.move(QPoint(40, 30));
|
||||
graph.release(QPoint(40, 30));
|
||||
graph.press(QPoint(w - 2, 140));
|
||||
graph.release(QPoint(w - 2, 140));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; });
|
||||
graph.press(graph.at(0.505, 0.9));
|
||||
graph.move(graph.at(0.6, 0.9));
|
||||
graph.release(graph.at(0.6, 0.9));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.2}, {1, 1}}));
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
// Sixteen points take no seventeenth.
|
||||
graph.set([](CameraRawCurveSettings &curve) {
|
||||
curve.rgb = {{0, 0}};
|
||||
for (int index = 1; index < 15; ++index)
|
||||
curve.rgb.push_back({index * 0.06, index * 0.06});
|
||||
curve.rgb.push_back({1, 1});
|
||||
});
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
graph.press(graph.at(0.93, 0.2));
|
||||
graph.release(graph.at(0.93, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
// Fifteen take a sixteenth.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); });
|
||||
graph.press(graph.at(0.93, 0.2));
|
||||
graph.release(graph.at(0.93, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; });
|
||||
const QColor accent = graph.graph->palette().color(QPalette::Highlight);
|
||||
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
const auto shown = [&graph](size_t index) {
|
||||
const CurvePoint point = graph.curve().rgb[index];
|
||||
return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255));
|
||||
};
|
||||
// The press takes the point to the pointer.
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007);
|
||||
QCOMPARE(graph.dot(0.3, 0.4), accent);
|
||||
QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white));
|
||||
QCOMPARE(graph.readout().text(), shown(1));
|
||||
// The readout follows the drag.
|
||||
graph.move(graph.at(0.4, 0.5));
|
||||
QCOMPARE(graph.readout().text(), shown(1));
|
||||
QVERIFY(graph.readout().text().startsWith("In 102 Out 12"));
|
||||
graph.release(graph.at(0.4, 0.5));
|
||||
// An end point is chosen too.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.release(graph.at(1, 1));
|
||||
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
|
||||
QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent);
|
||||
// A new point is chosen as it is made.
|
||||
graph.press(graph.at(0.85, 0.2));
|
||||
graph.release(graph.at(0.85, 0.2));
|
||||
QCOMPARE(graph.readout().text(), shown(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003);
|
||||
QCOMPARE(graph.dot(0.85, 0.2), accent);
|
||||
// A choice past the points changed elsewhere hides.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.release(graph.at(1, 1));
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); });
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
QVERIFY(graph.readout().isVisible());
|
||||
// Another channel: no choice, and the held drag lets go.
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("pointChannel1"))->click();
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
|
||||
graph.move(graph.at(0.3, 0.9));
|
||||
QVERIFY(graph.curve().red == (std::vector<CurvePoint>{{0, 0}, {0.3, 0.4}, {1, 1}}));
|
||||
graph.release(graph.at(0.3, 0.9));
|
||||
// A preset's points drop it too; Custom keeps it.
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
graph.release(graph.at(0.3, 0.4));
|
||||
auto &preset = *graph.controls->findChild<QComboBox *>(QStringLiteral("curvePreset"));
|
||||
emit preset.activated(0);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; });
|
||||
QVERIFY(graph.readout().isVisible());
|
||||
emit preset.activated(2);
|
||||
QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast());
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5)));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
// On empty ground: one click adds, two remove.
|
||||
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8)));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
// A move after it drags nothing.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3));
|
||||
graph.move(graph.at(0.5, 0.9));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.6, 0.6}, {1, 1}}));
|
||||
// An end point stays, though chosen.
|
||||
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.press(graph.at(0.5, 0.8), Qt::RightButton);
|
||||
graph.move(graph.at(0.5, 0.2), Qt::RightButton);
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.5}, {1, 1}}));
|
||||
// A move without the left button drags nothing.
|
||||
graph.press(graph.at(0.5, 0.5));
|
||||
graph.move(graph.at(0.5, 0.9), Qt::RightButton);
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01);
|
||||
graph.release(graph.at(0.5, 0.5));
|
||||
// A lost release: the next press picks its own target.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.3));
|
||||
graph.press(graph.at(0.7, 0.7));
|
||||
graph.move(graph.at(0.7, 0.9));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01);
|
||||
// A release lets go: moves then change nothing.
|
||||
graph.release(graph.at(0.7, 0.9));
|
||||
const std::vector<CurvePoint> held = graph.curve().rgb;
|
||||
graph.move(graph.at(0.5, 0.1));
|
||||
QVERIFY(graph.curve().rgb == held);
|
||||
// Parametric too: a release ends the region.
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage0"))->click();
|
||||
graph.press(QPoint(30, 80));
|
||||
graph.release(QPoint(30, 80));
|
||||
graph.move(QPoint(30, 10));
|
||||
QCOMPARE(graph.curve().shadows, 0.0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes()
|
||||
{
|
||||
Graph graph;
|
||||
QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
|
||||
QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it."));
|
||||
}
|
||||
|
||||
QTEST_MAIN(CameraRawCurveGraphTests)
|
||||
#include "CameraRawCurveGraphTests.moc"
|
||||
@@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers()
|
||||
QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider));
|
||||
QCOMPARE(group.raw().curve.*member, 0.0);
|
||||
}
|
||||
// A drag moves the nearest divider, within 2 and 98.
|
||||
// Along the bottom: the nearest divider, kept ordered.
|
||||
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
press(graph, QPoint(int(graph.width() * 0.3), 75));
|
||||
drag(graph, QPoint(int(graph.width() * 0.35), 75));
|
||||
const int h = graph.height();
|
||||
press(graph, QPoint(int(graph.width() * 0.3), h - 5));
|
||||
drag(graph, QPoint(int(graph.width() * 0.35), h - 5));
|
||||
QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50);
|
||||
drag(graph, QPoint(graph.width() + 40, 75));
|
||||
QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98);
|
||||
QCOMPARE(group.raw().curve.shadowSplit, 48.0);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75));
|
||||
// Above it a drag lifts the region under the press.
|
||||
press(graph, QPoint(int(graph.width() * 0.3), 75));
|
||||
drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4));
|
||||
QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200));
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4));
|
||||
// A double click here leaves the point curve's points.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2));
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#include "CameraRawFixtures.h"
|
||||
#include <QtTest>
|
||||
#include <cmath>
|
||||
|
||||
// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model.
|
||||
using namespace CameraRawFixtures;
|
||||
|
||||
namespace {
|
||||
// Swift's bend, written out apart from the code.
|
||||
double bent(double tone, double lower, double low, double upper, double high)
|
||||
{
|
||||
if (tone < lower && lower > 0)
|
||||
return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66));
|
||||
if (tone > upper && upper < 1)
|
||||
return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66));
|
||||
return tone;
|
||||
}
|
||||
|
||||
// The kernel's table lookup: linear between entries.
|
||||
double looked(const std::vector<float> &table, double value)
|
||||
{
|
||||
const double at = value * 255;
|
||||
const int below = int(std::floor(at));
|
||||
if (below >= 255)
|
||||
return table[255];
|
||||
return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below);
|
||||
}
|
||||
|
||||
double luminance(const std::array<int, 4> &pixel)
|
||||
{
|
||||
return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2];
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawCurveTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void parametricCurveIsSmooth();
|
||||
void parametricCurveMatchesPhotoshop();
|
||||
void curveDeepensColorLikePhotoshop();
|
||||
void theCurvePassesThroughEachBentAnchor();
|
||||
void eachRegionBendsOnlyItsRange();
|
||||
void theToneTableRunsThroughThePointCurve();
|
||||
void refineSaturationEasesOrStrengthens();
|
||||
};
|
||||
|
||||
void CameraRawCurveTests::parametricCurveIsSmooth()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
QCOMPARE(curve.parametric(0.3), 0.3);
|
||||
for (const std::array<double, 4> &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) {
|
||||
curve.shadows = amounts[0];
|
||||
curve.darks = amounts[1];
|
||||
curve.lights = amounts[2];
|
||||
curve.highlights = amounts[3];
|
||||
std::vector<double> samples;
|
||||
for (int index = 0; index <= 200; ++index)
|
||||
samples.push_back(curve.parametric(index / 200.0));
|
||||
QCOMPARE(samples.front(), 0.0);
|
||||
QCOMPARE(samples.back(), 1.0);
|
||||
for (size_t index = 1; index < samples.size(); ++index)
|
||||
QVERIFY(samples[index] >= samples[index - 1] - 1e-9);
|
||||
// Finer sampling halves the slope's jumps; a corner keeps them.
|
||||
const auto jump = [&curve](int steps) {
|
||||
std::vector<double> slopes;
|
||||
double last = curve.parametric(0);
|
||||
for (int index = 1; index <= steps; ++index) {
|
||||
const double value = curve.parametric(double(index) / steps);
|
||||
slopes.push_back((value - last) * steps);
|
||||
last = value;
|
||||
}
|
||||
double worst = 0;
|
||||
for (size_t index = 1; index < slopes.size(); ++index)
|
||||
worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1]));
|
||||
return worst;
|
||||
};
|
||||
QVERIFY(jump(400) < jump(200) * 0.7);
|
||||
}
|
||||
curve = CameraRawCurveSettings();
|
||||
curve.darks = 100;
|
||||
const double before = curve.parametric(0.4);
|
||||
curve.darkSplit = 70;
|
||||
QVERIFY(curve.parametric(0.4) > before);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::parametricCurveMatchesPhotoshop()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.darks = -51;
|
||||
curve.lights = 59;
|
||||
const std::pair<double, double> photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498},
|
||||
{0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}};
|
||||
for (const auto &[tone, expected] : photoshop)
|
||||
QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone))));
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::curveDeepensColorLikePhotoshop()
|
||||
{
|
||||
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
|
||||
CameraRawSettings settings;
|
||||
settings.curve.darks = -51;
|
||||
settings.curve.lights = 59;
|
||||
const std::array<int, 4> curved = pixels(settings.apply(orange))[0];
|
||||
QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1])));
|
||||
settings.curve.refineSaturation = -100;
|
||||
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
|
||||
QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.shadows = 40;
|
||||
curve.darks = -51;
|
||||
curve.lights = 59;
|
||||
curve.highlights = -30;
|
||||
curve.shadowSplit = 20;
|
||||
curve.darkSplit = 45;
|
||||
curve.lightSplit = 80;
|
||||
// Shadows and highlights first, then darks and lights.
|
||||
for (int index = 0; index <= 32; ++index) {
|
||||
const double x = index / 32.0;
|
||||
const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59);
|
||||
QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x)));
|
||||
}
|
||||
// Between anchors the smooth curve, not a straight line.
|
||||
const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2;
|
||||
QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::eachRegionBendsOnlyItsRange()
|
||||
{
|
||||
// Shadows and highlights bend past their dividers alone.
|
||||
CameraRawCurveSettings shadows;
|
||||
shadows.shadows = 100;
|
||||
QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05);
|
||||
QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9);
|
||||
QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9);
|
||||
CameraRawCurveSettings highlights;
|
||||
highlights.highlights = -100;
|
||||
QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05);
|
||||
QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9);
|
||||
// Darks cover all below the middle; lights all above it.
|
||||
CameraRawCurveSettings darks;
|
||||
darks.darks = 100;
|
||||
QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05);
|
||||
QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9);
|
||||
CameraRawCurveSettings lights;
|
||||
lights.lights = -100;
|
||||
QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02);
|
||||
QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9);
|
||||
// A divider at an end bends nothing past it.
|
||||
CameraRawCurveSettings ends;
|
||||
ends.shadows = 100;
|
||||
ends.highlights = 100;
|
||||
ends.shadowSplit = 0;
|
||||
ends.lightSplit = 100;
|
||||
for (int index = 0; index <= 32; ++index)
|
||||
QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::theToneTableRunsThroughThePointCurve()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.lights = 59;
|
||||
curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}};
|
||||
const std::vector<float> table = curve.toneTable();
|
||||
QCOMPARE(table.size(), size_t(256));
|
||||
CameraRawCurveSettings point;
|
||||
point.rgb = curve.rgb;
|
||||
// Without amounts, the point curve alone: 0.5 to 0.6.
|
||||
QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005);
|
||||
for (const int index : {0, 64, 128, 200, 255}) {
|
||||
const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))];
|
||||
QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index)));
|
||||
}
|
||||
QCOMPARE(table[255], 1.0f);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::refineSaturationEasesOrStrengthens()
|
||||
{
|
||||
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
|
||||
CameraRawSettings settings;
|
||||
settings.curve.darks = -51;
|
||||
settings.curve.lights = 59;
|
||||
// At zero each channel runs through the tone curve alone.
|
||||
const std::vector<float> table = settings.curve.toneTable();
|
||||
const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel)));
|
||||
// −100: the colour scaled to the curved brightness, hue kept.
|
||||
settings.curve.refineSaturation = -100;
|
||||
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
|
||||
const double mapped = looked(table, luminance(source) / 255) * 255;
|
||||
QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5);
|
||||
QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02);
|
||||
// −50: halfway between the two.
|
||||
settings.curve.refineSaturation = -50;
|
||||
const std::array<int, 4> half = pixels(settings.apply(orange))[0];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5);
|
||||
// +50: half again as far from luminance.
|
||||
settings.curve.refineSaturation = 50;
|
||||
const std::array<int, 4> stronger = pixels(settings.apply(orange))[0];
|
||||
const double lum = luminance(curved);
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0);
|
||||
QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(CameraRawCurveTests)
|
||||
#include "CameraRawCurveTests.moc"
|
||||
@@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls()
|
||||
QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve."));
|
||||
QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color."));
|
||||
QCOMPARE(group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")).toolTip(),
|
||||
QString("Drag a point. Click the curve to add one. Double-click a point to remove it."));
|
||||
QString("Drag a point. Click to add one. Double-click a point to remove it."));
|
||||
QCOMPARE(group.child<QWidget>(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point."));
|
||||
QCOMPARE(group.child<QAbstractButton>(QStringLiteral("curveTargeted")).toolTip(),
|
||||
QString("Drag on the picture to move the curve for the tone under the pointer."));
|
||||
@@ -126,20 +126,21 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves()
|
||||
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
const int w = graph.width(), h = graph.height();
|
||||
// With no inner point a drag moves nothing.
|
||||
// An end point keeps its x; its output follows.
|
||||
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2));
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&graph, &move);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40));
|
||||
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
|
||||
// An inner point is held within 0.02 and 0.98.
|
||||
QCOMPARE(group.raw().curve.rgb.size(), size_t(2));
|
||||
QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1}));
|
||||
// Past the start it stays a hundredth in.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2));
|
||||
QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&graph, &far);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2));
|
||||
QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1);
|
||||
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.01) < 1e-6);
|
||||
// A real double click: press, release, double click, release.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
doubleClick(graph, QPoint(w / 2, h / 2));
|
||||
|
||||
@@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows()
|
||||
checkRow(*group.controls, group.session, row);
|
||||
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
checkRow(*group.controls, group.session,
|
||||
{"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false,
|
||||
{"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false,
|
||||
48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)});
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ void CameraRawGroupWordsTests::everyViewCarriesSwiftsWords()
|
||||
curve.show();
|
||||
holds(*curve.controls, {"Parametric", "Point", "RGB", "Red", "Green", "Blue", "Parametric lifts tonal regions. Point places anchors on the curve.",
|
||||
"RGB changes brightness. Red, green, and blue also shift the color.",
|
||||
"Drag a divider to change which tones the neighboring sliders affect.", "Targeted Adjustment", "Preset"});
|
||||
"Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.", "Targeted Adjustment", "Preset"});
|
||||
Group<CameraRawMixerControls> mixer;
|
||||
mixer.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; });
|
||||
mixer.show();
|
||||
@@ -228,7 +228,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
Group<CameraRawCurveControls> curve;
|
||||
curve.show();
|
||||
auto &graph = curve.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
// Lights at 100 lift their region's middle by 0.22.
|
||||
// The drawn curve is the model's parametric curve.
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.lights = 100; });
|
||||
QImage drawn = graph.grab().toImage();
|
||||
const int w = drawn.width(), h = drawn.height();
|
||||
@@ -237,7 +237,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
for (int y = 0; y < h; ++y)
|
||||
if (qGray(drawn.pixel(int(0.625 * w), y)) > qGray(drawn.pixel(int(0.625 * w), brightest)))
|
||||
brightest = y;
|
||||
QVERIFY2(std::abs(brightest - (1 - 0.845) * h) < 3, qPrintable(QString::number(brightest)));
|
||||
QVERIFY2(std::abs(brightest - (1 - curve.raw().curve.parametric(0.625)) * h) < 3, qPrintable(QString::number(brightest)));
|
||||
// A write repaints the graph without being asked.
|
||||
struct Paints : QObject {
|
||||
int count = 0;
|
||||
@@ -253,10 +253,14 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
paints.count = 0;
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.darkSplit = 60; });
|
||||
QTRY_VERIFY(paints.count > 0);
|
||||
// The selected point is the last inner one.
|
||||
// No point is chosen until one is pressed.
|
||||
curve.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.2, 0.3}, {0.6, 0.7}, {1, 1}}; });
|
||||
QCOMPARE(curve.child<QLabel>(QStringLiteral("curveSelectedPoint")).text(), QString("In 153 Out 179"));
|
||||
auto &readout = curve.child<QLabel>(QStringLiteral("curveSelectedPoint"));
|
||||
QVERIFY(!readout.isVisible());
|
||||
QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(int(0.6 * graph.width()), int(0.3 * graph.height())));
|
||||
QVERIFY(readout.isVisible());
|
||||
QCOMPARE(readout.text(), QString("In 153 Out 179"));
|
||||
}
|
||||
|
||||
void CameraRawGroupWordsTests::widgetsFollowTheModelAndGuardsHold()
|
||||
|
||||
@@ -238,7 +238,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
|
||||
settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}};
|
||||
settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30};
|
||||
const CameraRawCurveSettings shape = curve.normalized();
|
||||
const std::vector<float> luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
|
||||
const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
|
||||
blue = shape.channelTable(shape.blue);
|
||||
std::vector<float> mixer;
|
||||
for (size_t index = 0; index < 8; ++index)
|
||||
@@ -253,7 +253,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
|
||||
float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f};
|
||||
for (const int visualize : {-1, 1}) {
|
||||
const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) {
|
||||
adjust_camera_raw_curve_color(bytes, w, h, stride, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
|
||||
adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
|
||||
grade.data(), 0.7, -0.3, visualize);
|
||||
});
|
||||
QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct));
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <cmath>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's GPU canvas tests, held against the CPU canvas.
|
||||
struct ContractScene {
|
||||
EditorSession session;
|
||||
CanvasView canvas{session};
|
||||
ContractScene(int width, int height, int canvasWidth, int canvasHeight)
|
||||
{
|
||||
session.createDocument(width, height);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(canvasWidth, canvasHeight);
|
||||
session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height));
|
||||
}
|
||||
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
|
||||
QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); }
|
||||
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
|
||||
// What the canvas shows at each document pixel's centre.
|
||||
QImage documentShot()
|
||||
{
|
||||
canvas.synchronizeDisplay();
|
||||
const QImage view = shot();
|
||||
const QSizeF size = session.document().value().size();
|
||||
const double zoom = session.viewport.pointsPerPixel();
|
||||
QImage result(size.toSize(), QImage::Format_ARGB32);
|
||||
for (int y = 0; y < result.height(); ++y) {
|
||||
for (int x = 0; x < result.width(); ++x)
|
||||
result.setPixel(x, y, view.pixel(int(origin().x() + (x + 0.5) * zoom), int(origin().y() + (y + 0.5) * zoom)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// The export over the checkerboard, as the canvas shows it.
|
||||
QImage exportedOnCanvas()
|
||||
{
|
||||
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
|
||||
const double zoom = session.viewport.pointsPerPixel();
|
||||
QImage result(exported.size(), QImage::Format_ARGB32_Premultiplied);
|
||||
for (int y = 0; y < result.height(); ++y) {
|
||||
for (int x = 0; x < result.width(); ++x) {
|
||||
const bool light = (int(std::floor((x + 0.5) * zoom / 10)) + int(std::floor((y + 0.5) * zoom / 10))) % 2 == 0;
|
||||
result.setPixel(x, y, QColor::fromRgbF(light ? 0.35 : 0.30, light ? 0.35 : 0.30, light ? 0.35 : 0.30).rgb());
|
||||
}
|
||||
}
|
||||
QPainter painter(&result);
|
||||
painter.drawImage(QRectF(result.rect()), exported);
|
||||
painter.end();
|
||||
return result.convertToFormat(QImage::Format_ARGB32);
|
||||
}
|
||||
int gapToExport(const QPainterPath &skip = QPainterPath()) { return gap(documentShot(), exportedOnCanvas(), skip); }
|
||||
// The worst channel gap inside the border, `skip` aside.
|
||||
static int gap(const QImage &one, const QImage &other, const QPainterPath &skip = QPainterPath())
|
||||
{
|
||||
int worst = 0;
|
||||
for (int y = 1; y < one.height() - 1; ++y) {
|
||||
for (int x = 1; x < one.width() - 1; ++x) {
|
||||
if (skip.contains(QPointF(x + 0.5, y + 0.5)))
|
||||
continue;
|
||||
const QRgb a = one.pixel(x, y), b = other.pixel(x, y);
|
||||
worst = std::max({worst, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))});
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
// A band round a path, where overlays draw.
|
||||
static QPainterPath band(const QPainterPath &path, double width = 6)
|
||||
{
|
||||
QPainterPathStroker stroker;
|
||||
stroker.setWidth(width);
|
||||
return stroker.createStroke(path);
|
||||
}
|
||||
};
|
||||
|
||||
// A mask dark on the left, light on the right.
|
||||
inline ImportedImage rampMask(int width, int height)
|
||||
{
|
||||
QImage mask(width, height, QImage::Format_Grayscale8);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
for (int x = 0; x < width; ++x)
|
||||
mask.scanLine(y)[x] = uchar(x * 255 / (width - 1));
|
||||
}
|
||||
return LayerMask::assetFrom(mask);
|
||||
}
|
||||
@@ -83,7 +83,6 @@ private slots:
|
||||
void theLiveCompositeDrawsWhatTheExportDraws();
|
||||
void adjustmentsShowAsTheExportDoes();
|
||||
void anAdjustmentsMaskClipsItOnTheCanvas();
|
||||
void grainFollowsTheViewOnTheCanvas();
|
||||
};
|
||||
|
||||
void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument()
|
||||
@@ -411,32 +410,5 @@ void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas()
|
||||
session.cancelBrush();
|
||||
}
|
||||
|
||||
void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas()
|
||||
{
|
||||
// Swift's canvas hands Grain the view's region, not the document's.
|
||||
Scene scene(40, 40, 120, 120);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray"));
|
||||
session.addAdjustment(AdjustmentKind::grain);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
LayerAdjustment grain = session.activeLayer().value().adjustment.value();
|
||||
GrainSettings strong = grain.grain();
|
||||
strong.amount = 100;
|
||||
// Pinned: under some seeds the pan keeps one pixel's tone.
|
||||
strong.seed = 7;
|
||||
grain.setGrain(strong);
|
||||
session.updateAdjustment(session.activeLayerID().value(), grain);
|
||||
session.zoom(1);
|
||||
QVERIFY(gapToExport(scene) > 20);
|
||||
// A pan moves the pattern under the same document pixel.
|
||||
const auto pixel = [&scene] {
|
||||
const QPointF origin = scene.origin();
|
||||
return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5));
|
||||
};
|
||||
const QRgb before = pixel();
|
||||
session.viewport.translate(QSizeF(3, 0));
|
||||
QVERIFY(pixel() != before);
|
||||
}
|
||||
|
||||
QTEST_MAIN(CanvasDisplayTests)
|
||||
#include "CanvasDisplayTests.moc"
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "CanvasContractFixtures.h"
|
||||
|
||||
// Compositor 1.4's GPU canvas contracts on the CPU canvas: edits.
|
||||
namespace {
|
||||
QUuid insertNoise(ContractScene &scene, int width, int height, quint32 seed, QPointF centre, int alpha = 255)
|
||||
{
|
||||
const QImage image = noise(width, height, seed, alpha);
|
||||
scene.session.insert(ImportedImage(image, image, QStringLiteral("Noise")), centre);
|
||||
return scene.session.activeLayerID().value();
|
||||
}
|
||||
|
||||
QPainterPath ellipse(const QRectF &rect)
|
||||
{
|
||||
QPainterPath path;
|
||||
path.addEllipse(rect);
|
||||
return path;
|
||||
}
|
||||
|
||||
bool finished(const std::function<void(std::function<void()>)> &start)
|
||||
{
|
||||
bool done = false;
|
||||
start([&done] { done = true; });
|
||||
return QTest::qWaitFor([&done] { return done; }, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasEditContractTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aNewMaskRevealsTheWholeLayer_data();
|
||||
void aNewMaskRevealsTheWholeLayer();
|
||||
void movingPixelsShowAsTheirCommit_data();
|
||||
void movingPixelsShowAsTheirCommit();
|
||||
void aGradientInASelectionShowsAsItsCommit();
|
||||
void aGradientThatCannotFillCancels();
|
||||
void aLiveMaskFromFurtherDownShowsAsTheExport();
|
||||
};
|
||||
|
||||
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer_data()
|
||||
{
|
||||
QTest::addColumn<double>("rotation");
|
||||
QTest::newRow("upright") << 0.0;
|
||||
QTest::newRow("turned") << 25.0;
|
||||
}
|
||||
|
||||
// Swift's aNewMaskRevealsTheWholeLayer: one white pixel, stretched.
|
||||
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer()
|
||||
{
|
||||
QFETCH(double, rotation);
|
||||
ContractScene scene(80, 70, 80, 70);
|
||||
const QUuid id = insertNoise(scene, 30, 20, 2, QPointF(40, 35));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform = LayerTransform{.origin = {10, 12}, .size = {38, 30}, .rotation = rotation};
|
||||
});
|
||||
scene.session.zoom(1);
|
||||
scene.session.selectLayer(id);
|
||||
const QImage before = scene.exported();
|
||||
scene.session.addLayerMask();
|
||||
QCOMPARE(scene.session.activeLayer().value().mask.value().asset.size(), QSize(1, 1));
|
||||
const QImage after = scene.exported();
|
||||
// Wherever the layer covered its pixel, nothing changes.
|
||||
int covered = 0;
|
||||
for (int y = 0; y < after.height(); ++y) {
|
||||
for (int x = 0; x < after.width(); ++x) {
|
||||
if (qAlpha(before.pixel(x, y)) < 255)
|
||||
continue;
|
||||
++covered;
|
||||
QVERIFY2(ContractScene::gap(before.copy(x - 1, y - 1, 3, 3), after.copy(x - 1, y - 1, 3, 3)) <= 1,
|
||||
qPrintable(QStringLiteral("%1, %2").arg(x).arg(y)));
|
||||
}
|
||||
}
|
||||
QVERIFY(covered > 900);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
void CanvasEditContractTests::movingPixelsShowAsTheirCommit_data()
|
||||
{
|
||||
QTest::addColumn<bool>("duplicate");
|
||||
QTest::newRow("moving") << false;
|
||||
QTest::newRow("duplicating") << true;
|
||||
}
|
||||
|
||||
// Swift's matchesWhileMovingPixels, on a masked layer at 60%.
|
||||
void CanvasEditContractTests::movingPixelsShowAsTheirCommit()
|
||||
{
|
||||
QFETCH(bool, duplicate);
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
const QUuid id = insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
|
||||
setMask(snapshot, id, rampMask(80, 60));
|
||||
record(snapshot, id).opacity = 0.6;
|
||||
});
|
||||
scene.session.zoom(1);
|
||||
scene.session.selectLayer(id);
|
||||
const QImage still = scene.documentShot();
|
||||
scene.session.applySelection(ellipse(QRectF(20, 15, 30, 24)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
QVERIFY(scene.session.beginPixelMove(duplicate));
|
||||
scene.session.movePixels(QSizeF(9.4, 5.6));
|
||||
const QImage moving = scene.documentShot();
|
||||
QVERIFY(finished([&](std::function<void()> done) { scene.session.finishPixelMove(std::move(done)); }));
|
||||
const QImage moved = scene.documentShot();
|
||||
// The ants run round the moved outline: left out.
|
||||
const QPainterPath ants = ContractScene::band(ellipse(QRectF(29, 21, 30, 24)));
|
||||
QVERIFY(ContractScene::gap(still, moving, ants) > 20);
|
||||
QVERIFY2(ContractScene::gap(moving, moved, ants) <= 1, qPrintable(QString::number(ContractScene::gap(moving, moved, ants))));
|
||||
QVERIFY(scene.gapToExport(ants) <= 1);
|
||||
// A move leaves a hole; a duplicate leaves its pixels.
|
||||
const QRgb left = scene.exported().pixel(24, 27);
|
||||
QCOMPARE(qAlpha(left) == 0, !duplicate);
|
||||
}
|
||||
|
||||
// Swift's matchesWhileDraggingAGradient: inside a selection, at 70%.
|
||||
void CanvasEditContractTests::aGradientInASelectionShowsAsItsCommit()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
scene.session.zoom(1);
|
||||
const QImage still = scene.documentShot();
|
||||
const QPainterPath outline = ellipse(QRectF(15, 10, 40, 30));
|
||||
scene.session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select"));
|
||||
GradientSettings settings;
|
||||
settings.opacity = 0.7;
|
||||
settings.style = GradientStyle::foregroundToBackground;
|
||||
scene.session.setGradientSettings(settings);
|
||||
scene.session.selectTool(NavigationTool::gradient);
|
||||
scene.session.beginGradient(QPointF(10, 30));
|
||||
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
|
||||
scene.session.endGradientDrag();
|
||||
const QImage pending = scene.documentShot();
|
||||
QVERIFY(finished([&](std::function<void()> done) { scene.session.commitGradient(std::move(done)); }));
|
||||
const QImage committed = scene.documentShot();
|
||||
// The ants, the gradient's line and its ends: overlays.
|
||||
QPainterPath line;
|
||||
line.moveTo(10, 30);
|
||||
line.lineTo(70, 30);
|
||||
const QPainterPath overlays = ContractScene::band(outline).united(ContractScene::band(line, 30));
|
||||
QVERIFY(ContractScene::gap(still, pending, overlays) > 20);
|
||||
QVERIFY2(ContractScene::gap(pending, committed, overlays) <= 1, qPrintable(QString::number(ContractScene::gap(pending, committed, overlays))));
|
||||
// Outside the selection the photo stays.
|
||||
QCOMPARE(scene.exported().pixel(3, 3), still.pixel(3, 3));
|
||||
}
|
||||
|
||||
// Swift's 5708ed3: a fill that fails cancels with the error.
|
||||
void CanvasEditContractTests::aGradientThatCannotFillCancels()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
const QImage before = scene.exported();
|
||||
const int count = scene.session.history.undoCount();
|
||||
scene.session.selectTool(NavigationTool::gradient);
|
||||
scene.session.beginGradient(QPointF(10, 30));
|
||||
QVERIFY(scene.session.gradientEdit());
|
||||
scene.session.gradientEdit()->raster->pixelLimit = 100;
|
||||
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
|
||||
QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value());
|
||||
QCOMPARE(scene.session.history.undoCount(), count);
|
||||
QCOMPARE(scene.exported(), before);
|
||||
}
|
||||
|
||||
// Swift's matchesALiveMaskOutsideAClippingStack: a turned, distant source.
|
||||
void CanvasEditContractTests::aLiveMaskFromFurtherDownShowsAsTheExport()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
const QUuid source = insertNoise(scene, 40, 30, 4, QPointF(35, 30), 140);
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).transform.rotation = 25; });
|
||||
insertNoise(scene, 16, 16, 5, QPointF(60, 20));
|
||||
const QUuid target = insertNoise(scene, 50, 40, 9, QPointF(40, 30));
|
||||
const QImage unlinked = scene.exported();
|
||||
QVERIFY(scene.session.linkMask(source, target));
|
||||
scene.session.zoom(1);
|
||||
QVERIFY(ContractScene::gap(unlinked, scene.exported()) > 20);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
QTEST_MAIN(CanvasEditContractTests)
|
||||
#include "CanvasEditContractTests.moc"
|
||||
@@ -10,6 +10,7 @@ private slots:
|
||||
void aStackBlendsOnceWithItsBasesMode();
|
||||
void laterChildrenCoverEarlierOnes();
|
||||
void aStackInheritsThePaintersOpacity();
|
||||
void stacksAndCoverageTakeTheDevicesPixels();
|
||||
};
|
||||
|
||||
void ClippingStackTests::init()
|
||||
@@ -138,5 +139,42 @@ void ClippingStackTests::aStackInheritsThePaintersOpacity()
|
||||
QCOMPARE(reds(surface), (std::array<int, 4>{127, 64, 16, 0}));
|
||||
}
|
||||
|
||||
// Compositor 1.4: surfaces in device pixels keep half units sharp.
|
||||
void ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels()
|
||||
{
|
||||
Scene scene;
|
||||
Layer white = placed(solid(4, 2, qRgba(255, 255, 255, 255)));
|
||||
white.transform = placedAt({0, 0}, {4, 2});
|
||||
Layer stripe = placed(solid(1, 2, qRgba(255, 0, 0, 255)));
|
||||
stripe.transform = placedAt({0.5, 0}, {1, 2});
|
||||
stripe.transform.sampling = LayerSampling::nearest;
|
||||
const QUuid base = scene.add(white);
|
||||
const QUuid child = scene.add(stripe);
|
||||
scene.layers[child].source = base;
|
||||
const auto row = [&](const std::vector<QUuid> &ids) {
|
||||
LiveMaskRenderer live = scene.renderer();
|
||||
live.prepareStacks(ids, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; });
|
||||
QImage surface = BrushRaster::context(8, 4, false);
|
||||
QPainter painter(&surface);
|
||||
painter.scale(2, 2);
|
||||
for (const QUuid &id : ids)
|
||||
live.drawComposite(id, painter);
|
||||
painter.end();
|
||||
QList<QRgb> pixels;
|
||||
for (int x = 0; x < 4; ++x)
|
||||
pixels << surface.pixel(x, 1);
|
||||
return pixels;
|
||||
};
|
||||
const QRgb red = qRgba(255, 0, 0, 255), clear = qRgba(0, 0, 0, 0), whiteness = qRgba(255, 255, 255, 255);
|
||||
QCOMPARE(row({base, child}), (QList<QRgb>{whiteness, red, red, whiteness}));
|
||||
// Coverage from a source further down: a layer between.
|
||||
const QUuid between = scene.add(placed(solid(2, 2, qRgba(0, 0, 0, 0))));
|
||||
const QUuid target = scene.add(white);
|
||||
scene.layers[child].source = std::nullopt;
|
||||
scene.layers[child].blendMode = LayerBlendMode::normal;
|
||||
scene.layers[target].source = child;
|
||||
QCOMPARE(row({child, between, target}), (QList<QRgb>{clear, whiteness, whiteness, clear}));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ClippingStackTests)
|
||||
#include "ClippingStackTests.moc"
|
||||
@@ -32,7 +32,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
{"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"},
|
||||
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"gridSettings", ""}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
|
||||
{"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+["},
|
||||
{"ungroupLayers", "Ctrl+Shift+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"}, {"colorRange", ""}, {"maskBlackAreas", ""},
|
||||
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
|
||||
@@ -72,7 +72,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
for (QAction *entry : menus[5]->actions())
|
||||
filters << entry->text();
|
||||
QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
|
||||
QCOMPARE(menus[6]->actions().size(), 22);
|
||||
QCOMPARE(menus[6]->actions().size(), 23);
|
||||
}
|
||||
|
||||
void CompositorMenusTests::fileEntriesFollowTheControllersGate()
|
||||
@@ -255,7 +255,7 @@ void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo()
|
||||
void CompositorMenusTests::layerEntriesFollowTheActiveLayer()
|
||||
{
|
||||
Bar bar;
|
||||
for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"})
|
||||
for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"})
|
||||
QVERIFY2(!bar.action(name).isEnabled(), name);
|
||||
bar.session().createDocument(8, 8);
|
||||
QVERIFY(bar.action("groupLayers").isEnabled() && bar.action("newBlankLayer").isEnabled());
|
||||
@@ -297,9 +297,16 @@ void CompositorMenusTests::layerEntriesFollowTheActiveLayer()
|
||||
bar.action("moveOutOfFolder").trigger();
|
||||
QCOMPARE(bar.session().activeLayer().value().parentID, std::nullopt);
|
||||
QVERIFY(!bar.action("moveOutOfFolder").isEnabled());
|
||||
QVERIFY(!bar.action("ungroupLayers").isEnabled());
|
||||
bar.session().selectLayers({lower}, lower);
|
||||
const std::optional<QUuid> outer = bar.session().activeLayer().value().parentID;
|
||||
bar.action("groupLayers").trigger();
|
||||
QVERIFY(bar.action("ungroupLayers").isEnabled() && bar.action("ungroupLayers").text() == "Ungroup Layers");
|
||||
bar.action("ungroupLayers").trigger();
|
||||
QVERIFY(outer && bar.session().activeLayer().value().parentID == outer && bar.session().history.undoName() == "Ungroup Layers");
|
||||
// A busy project closes every layer entry.
|
||||
bar.session().setIsProjectBusy(true);
|
||||
for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"})
|
||||
for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"})
|
||||
QVERIFY2(!bar.action(name).isEnabled(), name);
|
||||
}
|
||||
|
||||
|
||||
@@ -179,13 +179,13 @@ void DitherSheetTests::theControlsFollowTheStyle()
|
||||
dither.colors = DitherColors::twoColors;
|
||||
});
|
||||
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"}));
|
||||
// Style's menu: ten looks in four groups.
|
||||
// Style's menu: eleven looks in four groups.
|
||||
QComboBox &style = shown.child<QComboBox>("ditherStyle");
|
||||
QStringList items;
|
||||
for (int index = 0; index < style.count(); ++index)
|
||||
items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index));
|
||||
QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"}));
|
||||
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
|
||||
QCOMPARE(style.currentText(), QString("Mac Patterns"));
|
||||
// Its quick preview never shows the busy line.
|
||||
QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
|
||||
|
||||
@@ -67,11 +67,11 @@ void DitherTests::stylesKeepSwiftsNamesOrderAndGroups()
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
names << rawValue(each);
|
||||
QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"}));
|
||||
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
|
||||
QString groups;
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
groups += QString::number(ditherGroup(each));
|
||||
QCOMPARE(groups, QString("0011122233"));
|
||||
QCOMPARE(groups, QString("00111222333"));
|
||||
QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2));
|
||||
QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots));
|
||||
QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns));
|
||||
|
||||
@@ -119,7 +119,8 @@ void FilterSessionTests::updatesNormalizeGrowAndDropWhatPreviewOffHides()
|
||||
session->updateFilter(FilterSettings{.radius = 1000}, true);
|
||||
QCOMPARE(settings(*session).radius, 250.0);
|
||||
QCOMPARE(session->filterEdit().value().grownMargin, 752.0);
|
||||
QVERIFY(!session->filterEdit().value().preparedPreview);
|
||||
// The last preview stays up while the grown grid renders.
|
||||
QVERIFY(session->filterEdit().value().preparedPreview);
|
||||
QVERIFY(settled(*session) && session->filterEdit().value().preparedPreview);
|
||||
// Off: the preview and the queue go at once.
|
||||
const int revision = session->brushRevision();
|
||||
|
||||
@@ -34,6 +34,8 @@ class FilterTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep();
|
||||
void growingABlurKeepsThePreviewUpUntilTheNextOne();
|
||||
void aMaskKeepsItsPlaceUnderAPreview();
|
||||
void motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal();
|
||||
void addNoiseChangesColorButNeverAlphaAndMonochromaticKeepsGrays();
|
||||
void removeDistortionBendsAboutTheCenterAndOnlyPincushionCorrectionOpensTheCorners();
|
||||
@@ -70,6 +72,56 @@ void FilterTests::gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep()
|
||||
QCOMPARE(alpha(27), 1);
|
||||
}
|
||||
|
||||
// The last preview stays where made until replaced.
|
||||
void FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 20);
|
||||
QImage image = BrushRaster::context(40, 20, false);
|
||||
QPainter(&image).fillRect(0, 0, 20, 20, Qt::white);
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Half")));
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
session.updateFilter(FilterSettings{.radius = 2}, true);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
const QImage first = session.filterEdit().value().previewImage(layer.id).value();
|
||||
const LayerTransform firstPlace = session.displayedTransform(session.activeLayer().value());
|
||||
QVERIFY(firstPlace.size.width() > 40);
|
||||
QCOMPARE(session.displayedMaskPlacement(session.activeLayer().value()), std::nullopt);
|
||||
session.updateFilter(FilterSettings{.radius = 12}, true);
|
||||
QCOMPARE(session.filterEdit().value().previewImage(layer.id).value().cacheKey(), first.cacheKey());
|
||||
QCOMPARE(session.displayedTransform(session.activeLayer().value()), firstPlace);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
const QImage second = session.filterEdit().value().previewImage(layer.id).value();
|
||||
QVERIFY(second.cacheKey() != first.cacheKey());
|
||||
QVERIFY(session.displayedTransform(session.activeLayer().value()).size.width() > firstPlace.size.width());
|
||||
session.cancelFilter();
|
||||
}
|
||||
|
||||
// A covering mask covers; on a grown preview, old bounds.
|
||||
void FilterTests::aMaskKeepsItsPlaceUnderAPreview()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 20);
|
||||
QImage image = BrushRaster::context(40, 20, false);
|
||||
image.fill(Qt::white);
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("White")));
|
||||
session.addLayerMask();
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
session.selectLayerTarget(layer.id, false);
|
||||
session.beginFilter(FilterKind::addNoise);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
QVERIFY(session.filterEdit().value().previewImage(layer.id));
|
||||
QCOMPARE(session.displayedMaskPlacement(layer), std::nullopt);
|
||||
QCOMPARE(session.displayedTransform(layer), layer.transform);
|
||||
session.cancelFilter();
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
QCOMPARE(session.displayedMaskPlacement(layer), std::optional(layer.transform));
|
||||
QVERIFY(session.displayedTransform(layer).size.width() > 40);
|
||||
session.cancelFilter();
|
||||
}
|
||||
|
||||
void FilterTests::motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal()
|
||||
{
|
||||
const auto streak = [](double angle) {
|
||||
|
||||
@@ -71,8 +71,8 @@ private slots:
|
||||
void settingsKeepSwiftsDefaultsAndBounds();
|
||||
void nothingToChangeClosesWithoutAStep();
|
||||
void bloomGrowsItsGridAndIsTrimmed();
|
||||
void aPreviewForAnOlderGridLandsNothing();
|
||||
void aJobWaitingBehindAnotherEditTakesTheNewGrid();
|
||||
void aPreviewForAnOlderGridShowsOnThatGrid();
|
||||
void aJobWaitingBehindAnotherEditShowsOnItsGrid();
|
||||
void theVignettePickerPreviewsAndPutsBack();
|
||||
void theSheetShowsSwiftsControls();
|
||||
};
|
||||
@@ -264,28 +264,33 @@ void FinishingFilterTests::bloomGrowsItsGridAndIsTrimmed()
|
||||
QVERIFY(after.size.width() > before.size.width() && after.size.width() < before.size.width() + 2 * 74);
|
||||
}
|
||||
|
||||
void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing()
|
||||
void FinishingFilterTests::aPreviewForAnOlderGridShowsOnThatGrid()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(1200, 1200);
|
||||
session.insert(ImportedImage(filled(1200, 1200, QColor(90, 90, 90)), QImage(), QStringLiteral("Big")));
|
||||
session.beginFilter(FilterKind::bloomGlow);
|
||||
QTRY_VERIFY(!session.filterEdit().value().preparing);
|
||||
// Every landed preview matches the grid it shows on.
|
||||
// Every landed preview sits on the grid it came from.
|
||||
bool mismatched = false;
|
||||
int older = 0;
|
||||
QObject::connect(&session, &EditorSession::changed, &session, [&] {
|
||||
const std::optional<FilterEdit> &edit = session.filterEdit();
|
||||
if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size())
|
||||
if (!edit || !edit->preparedPreview)
|
||||
return;
|
||||
if (QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size)
|
||||
mismatched = true;
|
||||
older += edit->preparedPreview->size() != edit->previewSource.size();
|
||||
});
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 5}, true);
|
||||
const quint64 version = session.filterEdit().value().previewSourceVersion;
|
||||
const LayerTransform grid = session.filterEdit().value().grownTransform.value();
|
||||
// Wider than the grid: it grows mid-render.
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 40}, true);
|
||||
QCOMPARE(session.filterEdit().value().previewSourceVersion, version + 1);
|
||||
QVERIFY(session.filterEdit().value().grownTransform.value().size.width() > grid.size.width());
|
||||
QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing);
|
||||
QVERIFY(!mismatched);
|
||||
QVERIFY(!mismatched && older > 0);
|
||||
QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size());
|
||||
QCOMPARE(session.filterEdit().value().preparedTransform, session.filterEdit().value().grownTransform);
|
||||
// Preview off: nothing waits; the stale render ends it.
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 5}, true);
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 60}, false);
|
||||
@@ -293,7 +298,7 @@ void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing()
|
||||
QVERIFY(!session.filterEdit().value().preparedPreview);
|
||||
}
|
||||
|
||||
void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
void FinishingFilterTests::aJobWaitingBehindAnotherEditShowsOnItsGrid()
|
||||
{
|
||||
// The reviewer's sequence: a cancelled edit's worker still runs.
|
||||
EditorSession session;
|
||||
@@ -302,13 +307,13 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(session.filterEdit().value().preparing);
|
||||
session.cancelFilter();
|
||||
// Bloom's first job waits; Preview off grows its grid meanwhile.
|
||||
// Bloom's first job waits; Preview off grows the grid meanwhile.
|
||||
session.beginFilter(FilterKind::bloomGlow);
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 50}, false);
|
||||
bool mismatched = false, armed = true;
|
||||
QObject::connect(&session, &EditorSession::changed, &session, [&] {
|
||||
const std::optional<FilterEdit> &edit = session.filterEdit();
|
||||
if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size())
|
||||
if (edit && edit->preparedPreview && QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size)
|
||||
mismatched = true;
|
||||
// The waiting job starts: Preview comes back next turn.
|
||||
if (std::exchange(armed, false))
|
||||
@@ -317,6 +322,7 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
QTRY_VERIFY(!armed);
|
||||
QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing);
|
||||
QVERIFY(!mismatched);
|
||||
QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size());
|
||||
}
|
||||
|
||||
void FinishingFilterTests::theVignettePickerPreviewsAndPutsBack()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ImageResizer.h"
|
||||
#include <QTemporaryDir>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
// The session cases of Swift's GroupTests.
|
||||
@@ -42,6 +43,10 @@ private slots:
|
||||
void hiddenParentOverridesChildrenAndExportOrderFollowsGroups();
|
||||
void groupsRoundTripAndSurviveImageAndCanvasResize();
|
||||
void rowsOfASessionWithoutADocument();
|
||||
void ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes();
|
||||
void ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren();
|
||||
void ungroupingReleasesClippingThatNoLongerMakesSense();
|
||||
void ungroupingANestedOrEmptyFolder();
|
||||
};
|
||||
|
||||
void GroupSessionTests::nestedGroupsMoveOutCollapseAndDeleteUndo()
|
||||
@@ -249,5 +254,135 @@ void GroupSessionTests::rowsOfASessionWithoutADocument()
|
||||
QVERIFY(EditorSession().layerRows().empty());
|
||||
}
|
||||
|
||||
void GroupSessionTests::ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
session.addBlankLayer();
|
||||
const QUuid below = session.activeLayerID().value();
|
||||
QVERIFY(!session.canUngroupLayers());
|
||||
session.addGroup();
|
||||
const QUuid group = session.activeLayerID().value();
|
||||
session.addBlankLayer();
|
||||
const QUuid childA = session.activeLayerID().value();
|
||||
session.addBlankLayer();
|
||||
const QUuid childB = session.activeLayerID().value();
|
||||
session.selectLayer(std::nullopt);
|
||||
session.addBlankLayer();
|
||||
const QUuid above = session.activeLayerID().value();
|
||||
QCOMPARE(parentOf(session, childA), std::optional(group));
|
||||
session.selectLayer(group);
|
||||
session.toggleGroupExpansion(group);
|
||||
QVERIFY(session.collapsedGroupIDs().contains(group));
|
||||
QVERIFY(session.canUngroupLayers());
|
||||
const int undoCount = session.history.undoCount();
|
||||
const qsizetype count = qsizetype(session.document().value().layers.size());
|
||||
QSignalSpy changed(&session, &EditorSession::changed);
|
||||
session.ungroupLayers();
|
||||
QVERIFY(!changed.isEmpty());
|
||||
std::vector<QUuid> order;
|
||||
for (const ImageLayer &layer : session.document().value().layers)
|
||||
order.push_back(layer.id);
|
||||
QVERIFY(std::ranges::find(order, group) == order.end());
|
||||
QCOMPARE(parentOf(session, childA), std::nullopt);
|
||||
QCOMPARE(parentOf(session, childB), std::nullopt);
|
||||
// Spliced in where the folder sat.
|
||||
const auto at = [&](QUuid id) { return std::ranges::find(order, id) - order.begin(); };
|
||||
QCOMPARE(at(childA), at(below) + 1);
|
||||
QCOMPARE(at(childB), at(childA) + 1);
|
||||
QCOMPARE(at(above), at(childB) + 1);
|
||||
QCOMPARE(qsizetype(order.size()), count - 1);
|
||||
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{childA, childB}));
|
||||
QCOMPARE(session.activeLayerID(), std::optional(childA));
|
||||
QVERIFY(!session.collapsedGroupIDs().contains(group));
|
||||
QCOMPARE(session.history.undoCount(), undoCount + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Ungroup Layers"));
|
||||
session.undo();
|
||||
QCOMPARE(parentOf(session, childA), std::optional(group));
|
||||
session.redo();
|
||||
QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1);
|
||||
// A plain layer has nothing to unwrap; nothing changes.
|
||||
changed.clear();
|
||||
session.selectLayer(childA);
|
||||
changed.clear();
|
||||
session.ungroupLayers();
|
||||
QVERIFY(changed.isEmpty());
|
||||
QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1);
|
||||
}
|
||||
|
||||
void GroupSessionTests::ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
session.addBlankLayer();
|
||||
const QUuid base = session.activeLayerID().value();
|
||||
session.addBlankLayer();
|
||||
const QUuid clipped = session.activeLayerID().value();
|
||||
QVERIFY(session.linkMask(base, clipped));
|
||||
session.selectLayers({base, clipped}, base);
|
||||
session.groupSelectedLayers();
|
||||
session.ungroupLayers();
|
||||
for (const ImageLayer &layer : session.document().value().layers) {
|
||||
if (layer.id == clipped)
|
||||
QCOMPARE(layer.maskSourceID, std::optional(base));
|
||||
}
|
||||
}
|
||||
|
||||
void GroupSessionTests::ungroupingReleasesClippingThatNoLongerMakesSense()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
session.addBlankLayer();
|
||||
const QUuid outsideBase = session.activeLayerID().value();
|
||||
session.addBlankLayer();
|
||||
session.addBlankLayer();
|
||||
const QUuid childSource = session.activeLayerID().value();
|
||||
// linkMask allows a clip across a folder's edge.
|
||||
QVERIFY(session.linkMask(outsideBase, childSource));
|
||||
session.selectLayer(childSource);
|
||||
session.groupSelectedLayers();
|
||||
session.ungroupLayers();
|
||||
for (const ImageLayer &layer : session.document().value().layers) {
|
||||
if (layer.id == childSource)
|
||||
QCOMPARE(layer.maskSourceID, std::nullopt);
|
||||
}
|
||||
}
|
||||
|
||||
void GroupSessionTests::ungroupingANestedOrEmptyFolder()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
session.addGroup();
|
||||
const QUuid outer = session.activeLayerID().value();
|
||||
session.addGroup();
|
||||
const QUuid inner = session.activeLayerID().value();
|
||||
session.addBlankLayer();
|
||||
const QUuid child = session.activeLayerID().value();
|
||||
// Children join the folder's parent; a drag ends first.
|
||||
session.selectLayer(inner);
|
||||
session.beginOpacityEdit();
|
||||
session.setLayerOpacity(0.5);
|
||||
QVERIFY(!session.canUndo());
|
||||
session.ungroupLayers();
|
||||
QVERIFY(session.canUndo());
|
||||
QCOMPARE(session.history.undoName(), QString("Ungroup Layers"));
|
||||
QCOMPARE(parentOf(session, child), std::optional(outer));
|
||||
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{child});
|
||||
// An empty folder just goes, nothing left chosen.
|
||||
session.addGroup();
|
||||
const QUuid empty = session.activeLayerID().value();
|
||||
const qsizetype count = qsizetype(session.document().value().layers.size());
|
||||
session.ungroupLayers();
|
||||
QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1);
|
||||
QVERIFY(std::ranges::none_of(session.document().value().layers, [&](const ImageLayer &layer) { return layer.id == empty; }));
|
||||
QVERIFY(session.selectedLayerIDs().isEmpty() && !session.activeLayerID());
|
||||
// Busy, it rests.
|
||||
session.selectLayer(outer);
|
||||
session.setIsProjectBusy(true);
|
||||
QVERIFY(!session.canUngroupLayers());
|
||||
session.setIsProjectBusy(false);
|
||||
QVERIFY(session.canUngroupLayers());
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(GroupSessionTests)
|
||||
#include "GroupSessionTests.moc"
|
||||
@@ -214,8 +214,8 @@ void GroupTests::documentHierarchyFollowsItsLayers()
|
||||
shown.parentID = open.id;
|
||||
ImageLayer top("Top", document.size());
|
||||
document.layers = {folder, child, open, shown, top};
|
||||
QCOMPARE(names(document.hierarchyEntries()),
|
||||
QStringList({"Folder:0:hidden", "Child:1:hidden", "Open:0:shown", "Shown:1:shown", "Top:0:shown"}));
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, child.id, open.id, shown.id, top.id}));
|
||||
QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{shown.id, top.id}));
|
||||
QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>({open.id, shown.id, top.id}));
|
||||
const std::vector<ImageLayer> rendered = document.renderLayers();
|
||||
QCOMPARE(int(rendered.size()), 2);
|
||||
|
||||
@@ -83,12 +83,12 @@ void HueSaturationFailureTests::failuresReachTheSessionsError()
|
||||
|
||||
void HueSaturationFailureTests::theFilterFailsAsAContextWhenMemoryRunsOut()
|
||||
{
|
||||
// One pixel wide: its row list asks as its pixels.
|
||||
// Its copy asks 64 MB, past the limit.
|
||||
QImage tall(1, 16'000'000, QImage::Format_RGBA8888_Premultiplied);
|
||||
tall.fill(Qt::red);
|
||||
const HueSaturationJob job{tall, HueSaturationSettings(120), std::nullopt, QTransform(), false};
|
||||
malloc_trim(0);
|
||||
const AddressSpaceLimit limit(80ll * 1024 * 1024);
|
||||
const AddressSpaceLimit limit(16ll * 1024 * 1024);
|
||||
QVERIFY_THROWS_EXCEPTION(ExportError, HueSaturationFilter::run(job));
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ private slots:
|
||||
void eyedroppersRecenterWidenAndNarrowTheBand();
|
||||
void targetedAdjustmentPicksTheRangeUnderTheCursor();
|
||||
void samplingNeedsAColorRangeAndAColorfulPixel();
|
||||
void positiveSaturationMatchesPhotoshop();
|
||||
};
|
||||
|
||||
void HueSaturationTests::defaultsAreAnExactNoOp()
|
||||
@@ -319,5 +320,23 @@ void HueSaturationTests::samplingNeedsAColorRangeAndAColorfulPixel()
|
||||
QVERIFY(gray->hueSaturation().value().settings.band() == before);
|
||||
}
|
||||
|
||||
// Photoshop divides by what's left: +50 doubles, +100 saturates.
|
||||
void HueSaturationTests::positiveSaturationMatchesPhotoshop()
|
||||
{
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 50) - 0.4) < 1e-9);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.3, 62) - 0.3 / 0.38) < 1e-9);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 100), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.8, 50), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0, 100), 0.0);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.6, -50) - 0.3) < 1e-9);
|
||||
// Clamped past ±100.
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 150), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.6, -150), 0.0);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 0) - 0.2) < 1e-9);
|
||||
// A red at saturation 0.2, lightness 0.5, raised by 50.
|
||||
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(0.6, 0.4, 0.4, HueSaturationSettings(0, 50, 0));
|
||||
QVERIFY(std::abs(color.red - 0.7) < 1e-9 && std::abs(color.green - 0.3) < 1e-9 && std::abs(color.blue - 0.3) < 1e-9);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(HueSaturationTests)
|
||||
#include "HueSaturationTests.moc"
|
||||
@@ -92,8 +92,8 @@ void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes()
|
||||
void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
|
||||
{
|
||||
const std::vector<ShortcutDefinition> &all = ShortcutDefinition::all();
|
||||
// Swift's 112 since 1.1.7, less Hide Compositor.
|
||||
QCOMPARE(int(all.size()), 111);
|
||||
// Swift's 113 with Ungroup Layers, less Hide Compositor.
|
||||
QCOMPARE(int(all.size()), 112);
|
||||
QSet<QString> ids;
|
||||
for (const ShortcutDefinition &definition : all)
|
||||
ids.insert(definition.id());
|
||||
|
||||
@@ -22,6 +22,7 @@ private slots:
|
||||
void entriesFollowTheSelectionAndTheGates();
|
||||
void thumbnailsAndEffectsChooseWhatTheMenuTargets();
|
||||
void testContextMenuDuplicateFolderPreservesHierarchy();
|
||||
void aFoldersMenuOffersUngroup();
|
||||
};
|
||||
|
||||
void LayerContextMenuTests::testContextMenuContainsCoreLayerActions()
|
||||
@@ -312,5 +313,30 @@ void LayerContextMenuTests::testContextMenuDuplicateFolderPreservesHierarchy()
|
||||
QCOMPARE(session->document().value().layers.size(), 2 * children + 2);
|
||||
}
|
||||
|
||||
void LayerContextMenuTests::aFoldersMenuOffersUngroup()
|
||||
{
|
||||
const auto session = threeLayers();
|
||||
const QUuid lower = session->document().value().layers[0].id;
|
||||
session->selectLayers({lower}, lower);
|
||||
session->groupSelectedLayers();
|
||||
Shown shown(*session);
|
||||
// The folder sits lowest; the top row is none.
|
||||
session->selectLayer(std::nullopt);
|
||||
QVERIFY(!titles(*shown.menu(0)).contains("Ungroup Layers"));
|
||||
const int folder = int(shown.list.cells().size()) - 2;
|
||||
QVERIFY(shown.row(folder).layerID() != lower);
|
||||
std::unique_ptr<QMenu> menu = shown.menu(folder);
|
||||
const QStringList entries = titles(*menu);
|
||||
QCOMPARE(entries.indexOf("Ungroup Layers"), entries.indexOf("Group Selected Layers") + 1);
|
||||
QVERIFY(item(*menu, "ungroupLayers").isEnabled());
|
||||
session->setIsProjectBusy(true);
|
||||
QVERIFY(!item(*shown.menu(folder), "ungroupLayers").isEnabled());
|
||||
session->setIsProjectBusy(false);
|
||||
item(*shown.menu(folder), "ungroupLayers").trigger();
|
||||
QCOMPARE(session->history.undoName(), QString("Ungroup Layers"));
|
||||
QCOMPARE(session->document().value().layers.size(), size_t(3));
|
||||
QCOMPARE(session->selectedLayerIDs(), QSet<QUuid>{lower});
|
||||
}
|
||||
|
||||
QTEST_MAIN(LayerContextMenuTests)
|
||||
#include "LayerContextMenuTests.moc"
|
||||
@@ -36,7 +36,7 @@ private slots:
|
||||
void hoverOverRowsAndCtrlKeepTheCursorRight();
|
||||
void aSpentPressArmsNothingLater();
|
||||
void aMaskPressReleasedInAPopupIsOver();
|
||||
void altClicksOnAMaskThroughTheWindowChooseAndToggle();
|
||||
void altClicksOnAMaskThroughTheWindowShowItAloneAndBack();
|
||||
void anotherButtonsReleaseLeavesAMaskPress();
|
||||
};
|
||||
|
||||
@@ -74,15 +74,25 @@ void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors()
|
||||
QCOMPARE(image(shown.list.cursorFor(folderBody, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
|
||||
const QPoint folderStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4));
|
||||
QCOMPARE(image(shown.list.cursorFor(folderStrip, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
|
||||
// A mask thumbnail copies, or loads with Ctrl; busy, nothing.
|
||||
// Alt over a mask: an eye behind the pointer.
|
||||
session->selectLayer(shown.id(0));
|
||||
session->addLayerMask();
|
||||
LayerCell &top = *shown.list.cells().at(0);
|
||||
const QPoint mask = top.mapTo(&shown.list, top.maskThumbnail().geometry().center());
|
||||
QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
|
||||
QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio)));
|
||||
QCOMPARE(image(shown.list.cursorFor(mask, Qt::ControlModifier)), image(CanvasView::loadSelectionCursor(ratio)));
|
||||
// The pointer's hot spot; the eye sits below right.
|
||||
const QCursor eye = NativeLayerList::showMaskCursor(1);
|
||||
QCOMPARE(eye.hotSpot(), CanvasView::duplicateCursor(1).hotSpot());
|
||||
QCOMPARE(eye.pixmap().size(), QSize(29, 32));
|
||||
const QImage drawn = image(eye);
|
||||
QCOMPARE(drawn.pixelColor(22, 23), QColor(Qt::black));
|
||||
QVERIFY(drawn.pixelColor(21, 23).lightness() > 150);
|
||||
QCOMPARE(drawn.pixelColor(25, 23).alpha(), 255);
|
||||
QCOMPARE(drawn.copy(0, 0, 18, 18), image(CanvasView::duplicateCursor(1)).copy(0, 0, 18, 18));
|
||||
QCOMPARE(image(CanvasView::duplicateCursor(1)).pixelColor(23, 23).alpha(), 0);
|
||||
session->setIsImporting(true);
|
||||
QCOMPARE(shown.list.cursorFor(mask, Qt::AltModifier).shape(), Qt::ArrowCursor);
|
||||
QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio)));
|
||||
QCOMPARE(shown.list.cursorFor(mask, Qt::ControlModifier).shape(), Qt::ArrowCursor);
|
||||
QCOMPARE(shown.list.cursorFor(body, Qt::AltModifier).shape(), Qt::ArrowCursor);
|
||||
session->setIsImporting(false);
|
||||
@@ -260,7 +270,7 @@ void LayerDragGestureTests::aMaskPressReleasedInAPopupIsOver()
|
||||
QCOMPARE(startedDrags.back().rows, uuidString(shown.id(0)).toUtf8());
|
||||
}
|
||||
|
||||
void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle()
|
||||
void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowShowItAloneAndBack()
|
||||
{
|
||||
const auto session = sessionWithLayers(2);
|
||||
Shown shown(*session);
|
||||
@@ -272,11 +282,11 @@ void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle()
|
||||
const QPoint at = mask.mapTo(&shown.list, QPoint(5, 5));
|
||||
// The window sees the release first; the click still lands.
|
||||
QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, at);
|
||||
QVERIFY(session->isMaskSelected());
|
||||
QVERIFY(session->activeLayer().value().mask.value().isEnabled);
|
||||
QVERIFY(session->isMaskSelected() && session->viewsMaskAlone());
|
||||
// Again, the image; Shift no longer toggles the mask.
|
||||
QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, at);
|
||||
QVERIFY(session->isMaskSelected());
|
||||
QVERIFY(!session->activeLayer().value().mask.value().isEnabled);
|
||||
QVERIFY(session->isMaskSelected() && !session->viewsMaskAlone());
|
||||
QVERIFY(session->activeLayer().value().mask.value().isEnabled);
|
||||
QCOMPARE(startedDrags.size(), size_t(0));
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ private slots:
|
||||
void tabAndTheBrushKeysStayWithTheList();
|
||||
void aSliderDragReleasedOverAPopupStillEnds();
|
||||
void anotherButtonsReleaseLeavesTheSwipe();
|
||||
void altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask();
|
||||
void altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone();
|
||||
};
|
||||
|
||||
void LayerListFocusTests::aMenuBorrowingFocusKeepsTheRenameAndTheField()
|
||||
@@ -321,7 +321,7 @@ void LayerListFocusTests::anotherButtonsReleaseLeavesTheSwipe()
|
||||
QVERIFY(session->canUndo());
|
||||
}
|
||||
|
||||
void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask()
|
||||
void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone()
|
||||
{
|
||||
const auto session = sessionWithLayers(2);
|
||||
NativeLayerList list(*session);
|
||||
@@ -339,13 +339,13 @@ void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAM
|
||||
session->undo();
|
||||
QCOMPARE(layerWith(*session, top.layerID()).maskSourceID, std::optional(list.cells().at(1)->layerID()));
|
||||
session->redo();
|
||||
// Alt+Shift on a mask chooses it and toggles it.
|
||||
// Alt on a mask shows it alone; Shift adds nothing.
|
||||
session->selectLayers({top.layerID()}, top.layerID());
|
||||
session->addLayerMask();
|
||||
session->selectLayerTarget(top.layerID(), false);
|
||||
QTest::mouseClick(&top.maskThumbnail(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, QPoint(5, 5));
|
||||
QVERIFY(session->isMaskSelected());
|
||||
QVERIFY(!session->activeLayer().value().mask.value().isEnabled);
|
||||
QVERIFY(session->isMaskSelected() && session->viewsMaskAlone());
|
||||
QVERIFY(session->activeLayer().value().mask.value().isEnabled);
|
||||
}
|
||||
|
||||
void LayerListFocusTests::tabAndTheBrushKeysStayWithTheList()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's LayerOrder: the hierarchy kept until what shapes it changes.
|
||||
namespace {
|
||||
// Nested folders, some hidden, built from a fixed seed.
|
||||
std::vector<ImageLayer> tree(int count)
|
||||
{
|
||||
QRandomGenerator random(11);
|
||||
std::vector<ImageLayer> layers;
|
||||
std::vector<QUuid> folders;
|
||||
for (int index = 0; index < count; ++index) {
|
||||
ImageLayer layer(QString::number(index), QSizeF(10, 10));
|
||||
layer.isGroup = random.bounded(3) == 0;
|
||||
layer.isVisible = random.bounded(4) != 0;
|
||||
if (!folders.empty() && random.bounded(3) != 0)
|
||||
layer.parentID = folders[size_t(random.bounded(int(folders.size())))];
|
||||
if (layer.isGroup)
|
||||
folders.push_back(layer.id);
|
||||
layers.push_back(layer);
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
std::vector<ProjectLayerRecord> records(const std::vector<ImageLayer> &layers)
|
||||
{
|
||||
std::vector<ProjectLayerRecord> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.push_back(layer.hierarchyRecord());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class LayerOrderTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void orderVisibilityAndDrawingFollowTheHierarchy();
|
||||
void eachThingThatShapesItIsSeen();
|
||||
void sixtyFourFoldersDeepAtMost();
|
||||
void nothingShownDrawsNothing();
|
||||
void opacitiesMultiplyUpKnownFoldersAlone();
|
||||
};
|
||||
|
||||
void LayerOrderTests::orderVisibilityAndDrawingFollowTheHierarchy()
|
||||
{
|
||||
const std::vector<ImageLayer> layers = tree(60);
|
||||
const LayerOrder::Result result = LayerOrder::resolve(layers);
|
||||
std::vector<QUuid> order, drawn;
|
||||
QSet<QUuid> visible;
|
||||
for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(records(layers))) {
|
||||
order.push_back(entry.layer.id);
|
||||
if (entry.visible)
|
||||
visible.insert(entry.layer.id);
|
||||
}
|
||||
for (const ProjectLayerRecord &record : LayerHierarchy::visibleLayers(records(layers)))
|
||||
drawn.push_back(record.id);
|
||||
QCOMPARE(result.order, order);
|
||||
QCOMPARE(result.visible, visible);
|
||||
QCOMPARE(result.drawn, drawn);
|
||||
// Hidden folders and hidden layers both play a part.
|
||||
QVERIFY(visible.size() > 5 && int(visible.size()) < 50 && drawn.size() > 5 && qsizetype(drawn.size()) < visible.size());
|
||||
}
|
||||
|
||||
void LayerOrderTests::eachThingThatShapesItIsSeen()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer folder(QStringLiteral("Folder"), document.size());
|
||||
folder.isGroup = true;
|
||||
ImageLayer inside(QStringLiteral("Inside"), document.size());
|
||||
inside.parentID = folder.id;
|
||||
ImageLayer top(QStringLiteral("Top"), document.size());
|
||||
document.layers = {folder, inside, top};
|
||||
QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{inside.id, top.id}));
|
||||
// A hidden folder hides what it holds.
|
||||
document.layers[0].isVisible = false;
|
||||
QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{top.id});
|
||||
QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>{top.id});
|
||||
document.layers[0].isVisible = true;
|
||||
// Out of the folder: the order changes.
|
||||
document.layers[1].parentID = std::nullopt;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, inside.id, top.id}));
|
||||
document.layers[1].parentID = top.id;
|
||||
document.layers[2].isGroup = true;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, top.id, inside.id}));
|
||||
QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{inside.id});
|
||||
// A different layer of the same shape: its own id.
|
||||
document.layers[2].id = QUuid::createUuid();
|
||||
document.layers[1].parentID = document.layers[2].id;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, document.layers[2].id, inside.id}));
|
||||
}
|
||||
|
||||
void LayerOrderTests::sixtyFourFoldersDeepAtMost()
|
||||
{
|
||||
std::vector<ImageLayer> layers;
|
||||
for (int index = 0; index < 70; ++index) {
|
||||
ImageLayer layer(QString::number(index), QSizeF(10, 10));
|
||||
layer.isGroup = index < 69;
|
||||
if (index > 0)
|
||||
layer.parentID = layers.back().id;
|
||||
layers.push_back(layer);
|
||||
}
|
||||
const LayerOrder::Result result = LayerOrder::resolve(layers);
|
||||
QCOMPARE(int(result.order.size()), 65);
|
||||
QVERIFY(result.drawn.empty());
|
||||
}
|
||||
|
||||
void LayerOrderTests::nothingShownDrawsNothing()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer hidden(QStringLiteral("Hidden"), document.size());
|
||||
hidden.isVisible = false;
|
||||
// Two of one id: drawn, that throws; hidden, nothing draws.
|
||||
document.layers = {hidden, hidden};
|
||||
QVERIFY(document.renderLayers().empty());
|
||||
document.layers[1].isVisible = true;
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, document.renderLayers());
|
||||
}
|
||||
|
||||
void LayerOrderTests::opacitiesMultiplyUpKnownFoldersAlone()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer folder(QStringLiteral("Folder"), document.size());
|
||||
folder.isGroup = true;
|
||||
folder.opacity = 0.5;
|
||||
ImageLayer inside(QStringLiteral("Inside"), document.size());
|
||||
inside.parentID = folder.id;
|
||||
inside.opacity = 0.8;
|
||||
// A folder nobody holds: the walk stops at its own.
|
||||
ImageLayer stray(QStringLiteral("Stray"), document.size());
|
||||
stray.parentID = QUuid::createUuid();
|
||||
stray.opacity = 0.6;
|
||||
document.layers = {folder, inside, stray};
|
||||
const QHash<QUuid, double> opacities = document.effectiveOpacities();
|
||||
QCOMPARE(opacities.value(folder.id), 0.5);
|
||||
QCOMPARE(opacities.value(inside.id), 0.4);
|
||||
QCOMPARE(opacities.value(stray.id), 0.6);
|
||||
document.layers.push_back(inside);
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, document.effectiveOpacities());
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(LayerOrderTests)
|
||||
#include "LayerOrderTests.moc"
|
||||
+25
-11
@@ -188,22 +188,36 @@ void LayersPanelTests::theMaskButtonUsesTheSelection()
|
||||
panel.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&panel));
|
||||
auto &mask = find<QToolButton>(panel, "addLayerMask");
|
||||
QCOMPARE(mask.toolTip(), QString("Add layer mask"));
|
||||
QCOMPARE(mask.toolTip(), QString("Add layer mask (Alt-click for a black mask)"));
|
||||
// Alt-click: all black without a selection.
|
||||
QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier);
|
||||
QCOMPARE(session.history.undoName(), QString("Add Hide-All Mask"));
|
||||
session.undo();
|
||||
QTest::mouseClick(&mask, Qt::LeftButton);
|
||||
QCOMPARE(session.history.undoName(), QString("Add Reveal-All Mask"));
|
||||
session.undo();
|
||||
QPainterPath box;
|
||||
box.addRect(QRectF(20, 10, 30, 20));
|
||||
session.applySelection(box, SelectionMode::replace, "Select");
|
||||
QCOMPARE(mask.toolTip(), QString("Add layer mask (the selection becomes black)"));
|
||||
QCOMPARE(mask.toolTip(), QString("Add layer mask revealing the selection (Alt-click to hide it)"));
|
||||
// The button reveals the selection, as Photoshop's.
|
||||
QTest::mouseClick(&mask, Qt::LeftButton);
|
||||
QCOMPARE(session.history.undoName(), QString("Add Mask from Selection"));
|
||||
QCOMPARE(session.history.undoName(), QString("Reveal Selection"));
|
||||
QVERIFY(!session.selection().has_value() && session.isMaskSelected() && !mask.isEnabled());
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0);
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255);
|
||||
// The row's menu: black shows only the selection.
|
||||
session.undo();
|
||||
QVERIFY(session.selection().has_value());
|
||||
LayerCell &cell = *panel.list().cells().at(0);
|
||||
panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>("addBlackMask")->trigger();
|
||||
QCOMPARE(session.history.undoName(), QString("Add Mask from Selection"));
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255);
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0);
|
||||
// Alt-click hides it instead.
|
||||
session.undo();
|
||||
QVERIFY(session.selection().has_value());
|
||||
QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier);
|
||||
QCOMPARE(session.history.undoName(), QString("Hide Selection"));
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0);
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255);
|
||||
// The row's Hide All hides the selection too.
|
||||
session.undo();
|
||||
LayerCell &cell = *panel.list().cells().at(0);
|
||||
panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>("addBlackMask")->trigger();
|
||||
QCOMPARE(session.history.undoName(), QString("Hide Selection"));
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0);
|
||||
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "CanvasFixtures.h"
|
||||
#include "ContentView.h"
|
||||
#include "UI/LayerMaskMenu.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
|
||||
// Swift's MaskAloneTests: Alt-click shows a mask by itself.
|
||||
class MaskAloneTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt();
|
||||
void aLayerWithoutAMaskHasNothingToShow();
|
||||
void anotherLayerOrAnEndedViewShowsTheImage();
|
||||
void theCanvasShowsTheMaskInGrayInsteadOfTheImage();
|
||||
void aStrokeOrAGradientShowsInTheMaskAsItIsLaid();
|
||||
void aPlacedOrDisabledMaskShowsAlone();
|
||||
void theBadgeNamesTheMaskAndTakesItsOwnClicks();
|
||||
void altClickOnTheThumbnailTogglesItAndOutlinesItWhite();
|
||||
};
|
||||
|
||||
namespace {
|
||||
// A red layer, 200 by 100, its right half masked.
|
||||
QUuid masked(EditorSession &session)
|
||||
{
|
||||
session.insert(filled(200, 100, qRgba(255, 0, 0, 255), QStringLiteral("Red")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
QImage mask(200, 100, QImage::Format_Grayscale8);
|
||||
mask.fill(0);
|
||||
for (int y = 0; y < 100; ++y)
|
||||
std::fill_n(mask.scanLine(y), 100, uchar(255));
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {200, 100}};
|
||||
setMask(snapshot, id, LayerMask::assetFrom(mask));
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
std::unique_ptr<EditorSession> maskedSession(QUuid &id)
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(200, 100);
|
||||
id = masked(*session);
|
||||
return session;
|
||||
}
|
||||
|
||||
// The canvas at 1:1: view points are document pixels.
|
||||
struct Canvas : Shown {
|
||||
QUuid id;
|
||||
Canvas() : Shown(QSize(200, 100), QSize(400, 300))
|
||||
{
|
||||
settle();
|
||||
id = masked(session);
|
||||
session.zoom(1);
|
||||
canvas->synchronizeDisplay();
|
||||
}
|
||||
QColor at(QPointF point)
|
||||
{
|
||||
canvas->synchronizeDisplay();
|
||||
const QPointF view = session.viewport.viewPoint(point, documentSize());
|
||||
return canvas->grab().toImage().pixelColor(view.toPoint());
|
||||
}
|
||||
};
|
||||
|
||||
bool red(const QColor &colour)
|
||||
{
|
||||
return colour.red() > 204 && colour.blue() < 102 && colour.green() < 102;
|
||||
}
|
||||
|
||||
bool gray(const QColor &colour, int value)
|
||||
{
|
||||
return std::abs(colour.red() - value) <= 2 && colour.red() == colour.green() && colour.green() == colour.blue();
|
||||
}
|
||||
}
|
||||
|
||||
void MaskAloneTests::optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt()
|
||||
{
|
||||
QUuid id;
|
||||
const auto session = maskedSession(id);
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(session->maskAloneLayer().value().id == id && session->isMaskSelected() && session->viewsMaskAlone());
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(!session->maskAloneLayer() && session->isMaskSelected());
|
||||
session->toggleMaskAlone(id);
|
||||
session->selectLayerTarget(id, false);
|
||||
QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone());
|
||||
session->toggleMaskAlone(id);
|
||||
session->deleteLayerMask();
|
||||
QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone());
|
||||
session->undo();
|
||||
QVERIFY(!session->maskAloneLayer());
|
||||
}
|
||||
|
||||
void MaskAloneTests::aLayerWithoutAMaskHasNothingToShow()
|
||||
{
|
||||
QUuid id;
|
||||
const auto session = maskedSession(id);
|
||||
session->deleteLayerMask();
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(!session->maskAloneLayer() && !session->isMaskSelected() && !session->viewsMaskAlone());
|
||||
// A busy project leaves the target, so the view too.
|
||||
session->addLayerMask();
|
||||
session->selectLayerTarget(id, false);
|
||||
session->setIsProjectBusy(true);
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(!session->viewsMaskAlone());
|
||||
// Busy, another layer's targeted mask stays unshown too.
|
||||
session->setIsProjectBusy(false);
|
||||
session->addBlankLayer();
|
||||
const QUuid other = session->activeLayerID().value();
|
||||
session->addLayerMask();
|
||||
QVERIFY(session->isMaskSelected());
|
||||
session->setIsProjectBusy(true);
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(session->activeLayerID() == other && !session->viewsMaskAlone());
|
||||
}
|
||||
|
||||
void MaskAloneTests::anotherLayerOrAnEndedViewShowsTheImage()
|
||||
{
|
||||
QUuid id;
|
||||
const auto session = maskedSession(id);
|
||||
session->addBlankLayer();
|
||||
const QUuid other = session->activeLayerID().value();
|
||||
session->toggleMaskAlone(id);
|
||||
QVERIFY(session->maskAloneLayer().has_value());
|
||||
// Another layer targets its pixels: the image again.
|
||||
session->selectLayer(other);
|
||||
QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone());
|
||||
// Alt-clicking another layer's mask shows that one.
|
||||
session->addLayerMask(false);
|
||||
session->toggleMaskAlone(id);
|
||||
session->toggleMaskAlone(other);
|
||||
QCOMPARE(session->maskAloneLayer().value().id, other);
|
||||
// The badge's button: each change is announced.
|
||||
QSignalSpy changed(session.get(), &EditorSession::changed);
|
||||
session->setViewsMaskAlone(false);
|
||||
QVERIFY(!session->maskAloneLayer() && session->isMaskSelected());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
session->setViewsMaskAlone(false);
|
||||
QCOMPARE(changed.count(), 1);
|
||||
}
|
||||
|
||||
void MaskAloneTests::theCanvasShowsTheMaskInGrayInsteadOfTheImage()
|
||||
{
|
||||
Canvas shown;
|
||||
QVERIFY(red(shown.at(QPointF(50, 50))));
|
||||
shown.session.toggleMaskAlone(shown.id);
|
||||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||||
QVERIFY(gray(shown.at(QPointF(50, 50)), 255));
|
||||
QVERIFY(gray(shown.at(QPointF(150, 50)), 0));
|
||||
shown.session.toggleMaskAlone(shown.id);
|
||||
QVERIFY(shown.canvas->synchronizeDisplay());
|
||||
QVERIFY(red(shown.at(QPointF(50, 50))));
|
||||
// At a crisp zoom too, past the halving.
|
||||
shown.session.toggleMaskAlone(shown.id);
|
||||
shown.session.zoom(8);
|
||||
QVERIFY(gray(shown.at(QPointF(80.5, 50.5)), 255));
|
||||
QVERIFY(gray(shown.at(QPointF(120.5, 50.5)), 0));
|
||||
}
|
||||
|
||||
void MaskAloneTests::aStrokeOrAGradientShowsInTheMaskAsItIsLaid()
|
||||
{
|
||||
Canvas shown;
|
||||
EditorSession &session = shown.session;
|
||||
session.toggleMaskAlone(shown.id);
|
||||
session.selectTool(NavigationTool::brush);
|
||||
session.setMaskPaintWhite(false);
|
||||
session.setBrushSettings(brush(20, 1, 0, 0, 0));
|
||||
session.beginBrush(QPointF(50, 50));
|
||||
session.continueBrush(QPointF(60, 50));
|
||||
QVERIFY(gray(shown.at(QPointF(55, 50)), 0));
|
||||
QVERIFY(gray(shown.at(QPointF(55, 80)), 255));
|
||||
QVERIFY(session.finishBrushImmediately());
|
||||
QVERIFY(gray(shown.at(QPointF(55, 50)), 0));
|
||||
QVERIFY(session.maskAloneLayer().has_value());
|
||||
// A gradient shows as it is dragged, off its line.
|
||||
session.selectTool(NavigationTool::gradient);
|
||||
session.setMaskPaintWhite(true);
|
||||
session.beginGradient(QPointF(0, 50));
|
||||
session.moveGradient(std::nullopt, QPointF(200, 50));
|
||||
QVERIFY(session.gradientEdit().has_value());
|
||||
const QColor left = shown.at(QPointF(10, 80)), right = shown.at(QPointF(190, 80));
|
||||
// Before it, the right half was black.
|
||||
QVERIFY(gray(right, right.red()) && right.red() > 6 && right.red() < 128);
|
||||
// What shows is what the gradient commits.
|
||||
bool done = false;
|
||||
session.commitGradient([&] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
const QImage committed = session.activeLayer().value().mask.value().asset.image();
|
||||
QVERIFY(gray(left, committed.pixelColor(10, 80).red()) && gray(right, committed.pixelColor(190, 80).red()));
|
||||
QVERIFY(gray(shown.at(QPointF(10, 80)), left.red()));
|
||||
}
|
||||
|
||||
void MaskAloneTests::aPlacedOrDisabledMaskShowsAlone()
|
||||
{
|
||||
Canvas shown;
|
||||
EditorSession &session = shown.session;
|
||||
// Turned off, the composite ignores it; alone, it shows.
|
||||
session.selectLayerTarget(shown.id, true);
|
||||
session.toggleLayerMask();
|
||||
QVERIFY(red(shown.at(QPointF(150, 50))));
|
||||
session.toggleMaskAlone(shown.id);
|
||||
QVERIFY(gray(shown.at(QPointF(150, 50)), 0));
|
||||
QVERIFY(gray(shown.at(QPointF(50, 50)), 255));
|
||||
// Placed apart, at its placement; its edge tone past it.
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, shown.id).maskLinked = false;
|
||||
record(snapshot, shown.id).maskPlacement = LayerTransform{.origin = {50, 0}, .size = {100, 50}};
|
||||
});
|
||||
session.toggleMaskAlone(shown.id);
|
||||
QVERIFY(session.maskAloneLayer().has_value());
|
||||
QVERIFY(gray(shown.at(QPointF(60, 20)), 255));
|
||||
QVERIFY(gray(shown.at(QPointF(140, 20)), 0));
|
||||
const int edge = qRound(LayerMask::background(session.activeLayer().value().mask.value().asset.thumbnail) * 255);
|
||||
QVERIFY(gray(shown.at(QPointF(20, 80)), edge));
|
||||
QVERIFY(edge > 0 && gray(shown.at(QPointF(120, 70)), edge));
|
||||
}
|
||||
|
||||
void MaskAloneTests::theBadgeNamesTheMaskAndTakesItsOwnClicks()
|
||||
{
|
||||
EditorSession session;
|
||||
ContentView view(session);
|
||||
view.resize(900, 600);
|
||||
view.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&view));
|
||||
session.createDocument(200, 100);
|
||||
const QUuid id = masked(session);
|
||||
QVERIFY(!view.findChild<MaskAloneBadge *>()->isVisible());
|
||||
session.toggleMaskAlone(id);
|
||||
MaskAloneBadge *badge = nullptr;
|
||||
QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible());
|
||||
QCOMPARE(badge->findChild<QLabel *>("maskAloneTitle")->text(), QString("Layer Mask"));
|
||||
QCOMPARE(badge->findChild<QLabel *>("maskAloneName")->text(), QString("Red"));
|
||||
QCOMPARE(badge->height(), 26);
|
||||
QCOMPARE(badge->layout()->contentsMargins(), QMargins(11, 0, 8, 0));
|
||||
QCOMPARE(badge->layout()->spacing(), 7);
|
||||
// At the canvas's foot, 14 points up, in the middle.
|
||||
auto *canvas = view.findChild<CanvasView *>();
|
||||
const QRect onView(badge->mapTo(&view, QPoint(0, 0)), badge->size()), canvasOnView(canvas->mapTo(&view, QPoint(0, 0)), canvas->size());
|
||||
QCOMPARE(canvasOnView.bottom() - onView.bottom(), 14);
|
||||
QVERIFY(std::abs(onView.center().x() - canvasOnView.center().x()) <= 1);
|
||||
// A long name ends in an ellipsis within 220 points.
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).name = QString(80, QLatin1Char('W')); });
|
||||
session.toggleMaskAlone(id);
|
||||
QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible());
|
||||
QLabel &name = *badge->findChild<QLabel *>("maskAloneName");
|
||||
QVERIFY(name.width() <= 220 && name.text().endsWith(QChar(0x2026)));
|
||||
// Its button, not the canvas under it, takes the click.
|
||||
auto &close = *badge->findChild<QToolButton *>("maskAloneClose");
|
||||
QCOMPARE(close.toolTip(), QString("Show the image again (or Alt-click the mask thumbnail)"));
|
||||
QCOMPARE(close.accessibleName(), QString("Stop viewing the mask"));
|
||||
const QPoint middle = close.mapTo(view.window(), close.rect().center());
|
||||
QCOMPARE(view.window()->childAt(middle), &close);
|
||||
QTest::mouseClick(view.window()->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle);
|
||||
QVERIFY(!session.maskAloneLayer() && session.isMaskSelected());
|
||||
QTRY_VERIFY(!badge->isVisible());
|
||||
// The canvas under the badge's foot still takes clicks.
|
||||
session.toggleMaskAlone(id);
|
||||
QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible());
|
||||
const QPoint below = badge->mapTo(view.window(), QPoint(badge->width() / 2, badge->height() + 7));
|
||||
QCOMPARE(view.window()->childAt(below), canvas);
|
||||
}
|
||||
|
||||
void MaskAloneTests::altClickOnTheThumbnailTogglesItAndOutlinesItWhite()
|
||||
{
|
||||
EditorSession session;
|
||||
NativeLayerList list(session);
|
||||
list.resize(300, 400);
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&list));
|
||||
session.createDocument(200, 100);
|
||||
const QUuid id = masked(session);
|
||||
LayerCell &cell = *list.cells().at(0);
|
||||
auto *thumbnail = cell.findChild<LayerThumbnailButton *>("maskThumbnail");
|
||||
QVERIFY(thumbnail);
|
||||
QCOMPARE(thumbnail->toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black "
|
||||
"areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
|
||||
session.selectLayerTarget(id, false);
|
||||
const QPoint middle = thumbnail->rect().center();
|
||||
QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier, middle);
|
||||
QCOMPARE(session.maskAloneLayer().value().id, id);
|
||||
// Outlined in white rather than the accent.
|
||||
const QColor ring = thumbnail->grab().toImage().pixelColor(1, middle.y());
|
||||
QCOMPARE(ring, QColor(Qt::white));
|
||||
// Released off the thumbnail, the press does nothing.
|
||||
QTest::mousePress(thumbnail, Qt::LeftButton, Qt::AltModifier, middle);
|
||||
QTest::mouseRelease(thumbnail, Qt::LeftButton, Qt::AltModifier, QPoint(-40, middle.y()));
|
||||
QCOMPARE(session.maskAloneLayer().value().id, id);
|
||||
// Shift no longer toggles the mask on an Alt-click.
|
||||
QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, middle);
|
||||
QVERIFY(!session.maskAloneLayer() && session.activeLayer().value().mask.value().isEnabled);
|
||||
const QColor accent = thumbnail->grab().toImage().pixelColor(1, middle.y());
|
||||
QCOMPARE(accent, thumbnail->palette().color(QPalette::Highlight));
|
||||
}
|
||||
|
||||
QTEST_MAIN(MaskAloneTests)
|
||||
#include "MaskAloneTests.moc"
|
||||
@@ -469,7 +469,7 @@ void NativeLayerListTests::ctrlClicksOnThumbnailsLoadSelections()
|
||||
Shown shown(session);
|
||||
LayerCell &row = shown.row(0);
|
||||
QCOMPARE(row.thumbnail().toolTip(), QString("Select image pixels"));
|
||||
QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
|
||||
QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
|
||||
// Ctrl on the picture: its opaque pixels, the row untouched.
|
||||
QTest::mouseClick(&row.thumbnail(), Qt::LeftButton, Qt::ControlModifier);
|
||||
QCOMPARE(session.history.undoName(), QString("Load Layer Selection"));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QClipboard>
|
||||
#include <QImageReader>
|
||||
#include <QImageWriter>
|
||||
#include <QMenu>
|
||||
#include <QtTest>
|
||||
|
||||
// The welcome sheet: sizes, its three ways on, the clipboard.
|
||||
@@ -16,6 +17,31 @@ struct Sheet {
|
||||
QLineEdit &height = *sheet.findChild<QLineEdit *>("heightInput");
|
||||
QLabel ¬e = *sheet.findChild<QLabel *>("canvasNote");
|
||||
QPushButton &create = *sheet.findChild<QPushButton *>("createCanvas");
|
||||
QToolButton &presets = *sheet.findChild<QToolButton *>("presetSizes");
|
||||
|
||||
// The menu's entries, a separator as "-".
|
||||
QStringList entries() const
|
||||
{
|
||||
QStringList titles;
|
||||
for (const QAction *action : presets.menu()->actions())
|
||||
titles << (action->isSeparator() ? QStringLiteral("-") : action->text());
|
||||
return titles;
|
||||
}
|
||||
QString checked() const
|
||||
{
|
||||
QStringList titles;
|
||||
for (const QAction *action : presets.menu()->actions())
|
||||
if (action->isChecked())
|
||||
titles << action->text();
|
||||
return titles.join(',');
|
||||
}
|
||||
QAction &entry(const QString &title) const
|
||||
{
|
||||
for (QAction *action : presets.menu()->actions())
|
||||
if (action->text() == title)
|
||||
return *action;
|
||||
throw std::runtime_error("no such preset");
|
||||
}
|
||||
};
|
||||
|
||||
QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone)
|
||||
@@ -42,6 +68,9 @@ private slots:
|
||||
void aCopiedPicturesSizeIsSuggestedOnce();
|
||||
void theFirstTabSkipsTheClipboardOnce();
|
||||
void clipboardSizesComeFromHeadersAlone();
|
||||
void presetsAreSwiftsGroups();
|
||||
void aPresetFillsTheFieldsAndTheMatchIsChecked();
|
||||
void theMoreButtonDrawsThreeDotsAndOpensTheMenu();
|
||||
};
|
||||
|
||||
void NewCanvasSheetTests::cleanup()
|
||||
@@ -227,5 +256,89 @@ void NewCanvasSheetTests::clipboardSizesComeFromHeadersAlone()
|
||||
QCOMPARE(NewCanvasSheet::clipboardDimensions(&turned), std::optional(QSize(50, 30)));
|
||||
}
|
||||
|
||||
void NewCanvasSheetTests::presetsAreSwiftsGroups()
|
||||
{
|
||||
QStringList sizes;
|
||||
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
|
||||
for (const CanvasPreset &preset : group)
|
||||
sizes << QStringLiteral("%1 %2x%3").arg(preset.title).arg(preset.width).arg(preset.height);
|
||||
sizes << "-";
|
||||
}
|
||||
QCOMPARE(sizes, (QStringList{"4K 3840x2160", "1440p 2560x1440", "1080p 1920x1080", "-", "iPhone 18 Pro 1206x2622", "iPhone 18 Pro Max 1320x2868",
|
||||
"MacBook Pro 14\" 3024x1964", "MacBook Pro 16\" 3456x2234", "Studio Display 5120x2880", "-", "Instagram Square 1080x1080",
|
||||
"Instagram Portrait 1080x1350", "Instagram Story 1080x1920", "YouTube Thumb 1080x608", "-"}));
|
||||
QCOMPARE(CanvasPreset::all.size(), size_t(12));
|
||||
QCOMPARE(CanvasPreset::all.back().title, QString("YouTube Thumb"));
|
||||
Sheet fixture;
|
||||
QCOMPARE(fixture.entries(), (QStringList{"Custom", "-", "4K", "1440p", "1080p", "-", "iPhone 18 Pro", "iPhone 18 Pro Max", "MacBook Pro 14\"",
|
||||
"MacBook Pro 16\"", "Studio Display", "-", "Instagram Square", "Instagram Portrait", "Instagram Story",
|
||||
"YouTube Thumb"}));
|
||||
QCOMPARE(fixture.presets.toolTip(), QString("Preset sizes for screens and common formats"));
|
||||
QCOMPARE(fixture.presets.accessibleName(), QString("Preset sizes"));
|
||||
QCOMPARE(fixture.presets.size(), QSize(28, 28));
|
||||
QCOMPARE(fixture.presets.popupMode(), QToolButton::InstantPopup);
|
||||
}
|
||||
|
||||
void NewCanvasSheetTests::aPresetFillsTheFieldsAndTheMatchIsChecked()
|
||||
{
|
||||
Sheet fixture;
|
||||
QCOMPARE(fixture.checked(), QString("1080p"));
|
||||
fixture.entry("Instagram Story").trigger();
|
||||
QCOMPARE(fixture.width.text(), QString("1080"));
|
||||
QCOMPARE(fixture.height.text(), QString("1920"));
|
||||
QCOMPARE(fixture.checked(), QString("Instagram Story"));
|
||||
fixture.entry("Studio Display").trigger();
|
||||
QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("5120x2880"));
|
||||
QCOMPARE(fixture.checked(), QString("Studio Display"));
|
||||
// A size no preset has is Custom, matched as text.
|
||||
fixture.height.setText("2881");
|
||||
QCOMPARE(fixture.checked(), QString("Custom"));
|
||||
fixture.height.setText("2880");
|
||||
QCOMPARE(fixture.checked(), QString("Studio Display"));
|
||||
fixture.width.setText("05120");
|
||||
QCOMPARE(fixture.checked(), QString("Custom"));
|
||||
fixture.width.setText("1080");
|
||||
fixture.height.setText("608");
|
||||
QCOMPARE(fixture.checked(), QString("YouTube Thumb"));
|
||||
// Custom fills nothing in.
|
||||
fixture.entry("Custom").trigger();
|
||||
QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("1080x608"));
|
||||
QCOMPARE(fixture.checked(), QString("YouTube Thumb"));
|
||||
fixture.entry("4K").trigger();
|
||||
QCOMPARE(fixture.checked(), QString("4K"));
|
||||
QVERIFY(fixture.create.isEnabled());
|
||||
QTest::mouseClick(&fixture.create, Qt::LeftButton);
|
||||
QCOMPARE(fixture.created, (QList<QSize>{QSize(3840, 2160)}));
|
||||
}
|
||||
|
||||
void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu()
|
||||
{
|
||||
Sheet fixture;
|
||||
fixture.sheet.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet));
|
||||
// Flush with the content's right edge, 28 points in.
|
||||
QCOMPARE(fixture.presets.geometry().right() + 1, 500 - 28);
|
||||
const QImage image = fixture.presets.grab().toImage();
|
||||
const QColor ground = image.pixelColor(2, 2), ink = fixture.presets.palette().color(QPalette::WindowText);
|
||||
// Dots centred at x 26.75, y 9, 14 and 19.
|
||||
for (const int y : {9, 14, 19})
|
||||
QVERIFY2(std::abs(qGray(image.pixel(26, y)) - qGray(ink.rgb())) < std::abs(qGray(image.pixel(26, y)) - qGray(ground.rgb())), qPrintable(QString::number(y)));
|
||||
for (const QPoint clear : {QPoint(26, 11), QPoint(26, 16), QPoint(26, 5), QPoint(26, 23), QPoint(22, 14), QPoint(14, 14)})
|
||||
QCOMPARE(image.pixelColor(clear), ground);
|
||||
// Filled, never outlined: a red ink stays red.
|
||||
QPalette red = fixture.presets.palette();
|
||||
red.setColor(QPalette::WindowText, Qt::red);
|
||||
fixture.presets.setPalette(red);
|
||||
const QColor centre = fixture.presets.grab().toImage().pixelColor(26, 14);
|
||||
QVERIFY2(centre.red() > 200 && centre.red() - centre.green() > 60, qPrintable(centre.name()));
|
||||
bool shown = false;
|
||||
QTimer::singleShot(0, fixture.presets.menu(), [&] {
|
||||
shown = fixture.presets.menu()->isVisible();
|
||||
fixture.presets.menu()->close();
|
||||
});
|
||||
QTest::mouseClick(&fixture.presets, Qt::LeftButton);
|
||||
QVERIFY(shown);
|
||||
}
|
||||
|
||||
QTEST_MAIN(NewCanvasSheetTests)
|
||||
#include "NewCanvasSheetTests.moc"
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/PSD/PSDDocumentBuilder.h"
|
||||
#include "IO/PSD/PSDReader.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's PSDAdjustmentTests: adjustments and masks as Photoshop writes them.
|
||||
namespace {
|
||||
QByteArray shorts(const std::vector<int> &values)
|
||||
{
|
||||
QByteArray data;
|
||||
for (const int value : values) {
|
||||
const quint16 bits = quint16(qint16(value));
|
||||
data.append(char(bits >> 8)).append(char(bits & 0xFF));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// A white patch on a gray grid, as text rows.
|
||||
QStringList rows(const QImage &mask)
|
||||
{
|
||||
QStringList result;
|
||||
for (int y = 0; y < mask.height(); ++y) {
|
||||
QString row;
|
||||
for (int x = 0; x < mask.width(); ++x)
|
||||
row += mask.constScanLine(y)[x] > 127 ? u'#' : u'.';
|
||||
result << row;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QImage whitePatch()
|
||||
{
|
||||
QImage patch = BrushRaster::context(2, 2, true);
|
||||
patch.fill(255);
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
||||
class PSDAdjustmentTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void levelsGammaIsInHundredths();
|
||||
void hueSaturationReadsMasterAndEachRange();
|
||||
void maskPatchSitsWhereItIsOnTheCanvas();
|
||||
void maskPatchFollowsAPixelLayersGrid();
|
||||
};
|
||||
|
||||
void PSDAdjustmentTests::levelsGammaIsInHundredths()
|
||||
{
|
||||
// RGB 2–254 at gamma 2.50, the rest untouched; 29 records.
|
||||
std::vector<int> values{2, 2, 254, 0, 255, 250};
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
values.insert(values.end(), {0, 255, 0, 255, 100});
|
||||
QByteArray data = shorts(values);
|
||||
data.append(QByteArray(292 - data.size(), 0));
|
||||
const LevelsSettings levels = PSDAdjustments::levels(data).value().levels;
|
||||
QVERIFY(levels.ranges[0] == (LevelRange{.black = 2, .gamma = 2.5, .white = 254, .outputBlack = 0, .outputWhite = 255}));
|
||||
for (size_t channel = 1; channel < 4; ++channel)
|
||||
QCOMPARE(levels.ranges[channel].gamma, 1.0);
|
||||
QVERIFY(!PSDAdjustments::levels(data.left(291)));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::hueSaturationReadsMasterAndEachRange()
|
||||
{
|
||||
// Version 2, Colorize off; its values, the Master's, the Reds'.
|
||||
QByteArray data = shorts({2}) + QByteArray(2, 0) + shorts({23, 25, 0, 5, 4, 0, 315, 345, 15, 45, 0, -30, 10});
|
||||
// Yellows' band wraps: −30 reads as 330.
|
||||
data += shorts({-30, 15, 45, 75, 7, 8, 9});
|
||||
data += shorts(std::vector<int>(4 * 7, 0));
|
||||
const HueSaturationSettings settings = PSDAdjustments::hue(data).value().hsvSettings.value();
|
||||
QVERIFY(!settings.colorize);
|
||||
QVERIFY(settings.adjustments.at(ColorRange::master) == (RangeAdjustment{5, 4, 0}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10}));
|
||||
QVERIFY(settings.bands.at(ColorRange::reds) == (HueBand{315, 345, 15, 45}));
|
||||
QVERIFY(settings.bands.at(ColorRange::yellows) == (HueBand{330, 15, 45, 75}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::yellows) == (RangeAdjustment{7, 8, 9}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::magentas) == RangeAdjustment());
|
||||
|
||||
// Colorize on: its own values apply, and nothing else.
|
||||
data[2] = 1;
|
||||
const HueSaturationSettings colorized = PSDAdjustments::hue(data).value().hsvSettings.value();
|
||||
QVERIFY(colorized.colorize && colorized.adjustments.at(ColorRange::master) == (RangeAdjustment{23, 25, 0}));
|
||||
QVERIFY(!colorized.adjustments.contains(ColorRange::reds));
|
||||
QVERIFY(colorized.bands.at(ColorRange::reds) == defaultBand(ColorRange::reds));
|
||||
|
||||
// Cut short: the ranges that fit; under 16 bytes, none.
|
||||
data[2] = 0;
|
||||
const HueSaturationSettings cut = PSDAdjustments::hue(data.left(16 + 14 + 13)).value().hsvSettings.value();
|
||||
QVERIFY(cut.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10}));
|
||||
QVERIFY(!cut.adjustments.contains(ColorRange::yellows));
|
||||
QVERIFY(!PSDAdjustments::hue(data.left(15)));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::maskPatchSitsWhereItIsOnTheCanvas()
|
||||
{
|
||||
// A white patch at (3, 1), black round it.
|
||||
PSDRecord record;
|
||||
record.id = QUuid::createUuid();
|
||||
record.maskBounds = QRectF(3, 1, 2, 2);
|
||||
record.maskDefault = 0;
|
||||
const ImageLayer layer(QStringLiteral("Levels"), QSizeF(6, 4));
|
||||
const QImage mask = PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value();
|
||||
QCOMPARE(mask.size(), QSize(6, 4));
|
||||
QCOMPARE(rows(mask), QStringList({"......", "...##.", "...##.", "......"}));
|
||||
|
||||
// Through the builder, the patch at the bottom left.
|
||||
record.mask = whitePatch();
|
||||
record.maskBounds = QRectF(0, 2, 2, 2);
|
||||
record.adjustment = LayerAdjustment{.kind = AdjustmentKind::levels};
|
||||
record.kind = PSDLayerKind::adjustment;
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{6, 4, 72, {record}});
|
||||
QCOMPARE(rows(imported.layers.at(0).mask.value().asset.image()), QStringList({"......", "......", "##....", "##...."}));
|
||||
QCOMPARE(imported.conversions.size(), size_t(1));
|
||||
QCOMPARE(imported.conversions[0].message, QStringLiteral("Adjustment parameters may not match Photoshop exactly."));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::maskPatchFollowsAPixelLayersGrid()
|
||||
{
|
||||
QImage pixels = BrushRaster::context(4, 2, false);
|
||||
pixels.fill(Qt::red);
|
||||
ImageLayer layer(ImportedImage(pixels, pixels, QStringLiteral("Red")), QPointF(2, 1));
|
||||
PSDRecord record;
|
||||
record.maskBounds = QRectF(3, 1, 2, 2);
|
||||
record.maskDefault = 0;
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({".##.", ".##."}));
|
||||
// A layer stretched twice as wide: the patch is too.
|
||||
layer.transform.size = QSizeF(2, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"..##", "..##"}));
|
||||
// Already on the grid: the patch itself.
|
||||
QImage grid = BrushRaster::context(4, 2, true);
|
||||
record.maskBounds = QRectF(2, 1, 2, 2);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(grid, record, layer, QSizeF(6, 4)).value().cacheKey(), grid.cacheKey());
|
||||
// No bounds, or a sizeless layer: the patch stays.
|
||||
record.maskBounds = QRectF(2, 1, 0, 2);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
record.maskBounds = QRectF(2, 1, 2, 0);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
record.maskBounds = QRectF(2, 1, 2, 2);
|
||||
for (const QSizeF size : {QSizeF(0, 2), QSizeF(2, 0)}) {
|
||||
layer.transform.size = size;
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
}
|
||||
// The grid's bounds, a smaller patch: drawn up to it.
|
||||
layer.transform.size = QSizeF(4, 2);
|
||||
record.maskBounds = QRectF(2, 1, 4, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"####", "####"}));
|
||||
// The grid's size, a pixel along: placed there.
|
||||
QImage wide = BrushRaster::context(4, 2, true);
|
||||
wide.fill(255);
|
||||
record.maskBounds = QRectF(3, 1, 4, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(wide, record, layer, QSizeF(6, 4)).value()), QStringList({".###", ".###"}));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PSDAdjustmentTests)
|
||||
#include "PSDAdjustmentTests.moc"
|
||||
@@ -21,6 +21,7 @@ struct Record {
|
||||
uchar flags = 0;
|
||||
std::optional<QRect> mask;
|
||||
uchar maskFlags = 0;
|
||||
uchar maskDefault = 255;
|
||||
QByteArray name = "Layer";
|
||||
std::vector<Extra> extras;
|
||||
};
|
||||
@@ -81,7 +82,7 @@ inline QByteArray file(const std::vector<Record> &records, const QByteArray &res
|
||||
extra.i32(record.mask->left());
|
||||
extra.i32(record.mask->top() + record.mask->height());
|
||||
extra.i32(record.mask->left() + record.mask->width());
|
||||
extra.u8(255);
|
||||
extra.u8(record.maskDefault);
|
||||
extra.u8(record.maskFlags);
|
||||
extra.u16(0);
|
||||
} else {
|
||||
|
||||
@@ -167,6 +167,7 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels()
|
||||
Record record = pixels();
|
||||
record.mask = QRect(1, 1, 3, 1);
|
||||
record.maskFlags = flags;
|
||||
record.maskDefault = flags == 1 ? 0 : 255;
|
||||
record.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30", 5)});
|
||||
records.push_back(record);
|
||||
}
|
||||
@@ -178,12 +179,22 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels()
|
||||
QVERIFY(document.layers[1].maskEnabled && !document.layers[1].maskLinked);
|
||||
QVERIFY(!document.layers[2].maskEnabled && document.layers[2].maskLinked);
|
||||
QVERIFY(!document.layers[3].mask);
|
||||
QCOMPARE(document.layers[0].maskBounds, QRectF(1, 1, 3, 1));
|
||||
QVERIFY(document.layers[0].maskDefault == 255 && document.layers[1].maskDefault == 0);
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
|
||||
// On the layer's grid, the default round it.
|
||||
const QImage placed = imported.layers[0].mask.value().asset.image();
|
||||
QCOMPARE(placed.size(), QSize(2, 2));
|
||||
QVERIFY(placed.constScanLine(0)[0] == 255 && placed.constScanLine(0)[1] == 255);
|
||||
QVERIFY(placed.constScanLine(1)[0] == 255 && placed.constScanLine(1)[1] == 0x10);
|
||||
QCOMPARE(imported.layers[1].mask.value().asset.image().constScanLine(1)[0], uchar(0));
|
||||
QVERIFY(imported.layers[1].mask.value().isEnabled && !imported.layers[1].mask.value().isLinked);
|
||||
QVERIFY(!imported.layers[2].mask.value().isEnabled);
|
||||
// A mask that is not gray is reported, left off.
|
||||
PSDDocument coloured = document;
|
||||
coloured.layers[0].mask = PSDFixture::colorImage(3, 1, 1, 1, 1);
|
||||
// Already the layer's grid, so nothing converts it.
|
||||
coloured.layers[0].mask = PSDFixture::colorImage(2, 2, 1, 1, 1);
|
||||
coloured.layers[0].maskBounds = QRectF(0, 0, 2, 2);
|
||||
const PSDImport skipped = PSDDocumentBuilder::makeImport(coloured);
|
||||
QVERIFY(!skipped.layers[0].mask);
|
||||
QCOMPARE(messages(skipped), std::vector<QString>{"Layer: The layer mask couldn’t be converted to 8-bit grayscale and was skipped."});
|
||||
@@ -317,8 +328,8 @@ void PSDReaderTests::levelsCurvesAndHueParse()
|
||||
levels.bytes(QByteArray(292 - levels.data.size(), '\0'));
|
||||
const LayerAdjustment level = PSDAdjustments::parse({{"levl", levels.data}}).value();
|
||||
QVERIFY(level.kind == AdjustmentKind::levels);
|
||||
QCOMPARE(level.levels.ranges[0], (LevelRange{10, 2, 240, 5, 250}));
|
||||
QCOMPARE(level.levels.ranges[3], (LevelRange{13, 515 / 256.0, 243, 8, 253}));
|
||||
QCOMPARE(level.levels.ranges[0], (LevelRange{10, 5.12, 240, 5, 250}));
|
||||
QCOMPARE(level.levels.ranges[3], (LevelRange{13, 5.15, 243, 8, 253}));
|
||||
QVERIFY(!PSDAdjustments::parse({{"levl", levels.data.left(291)}}));
|
||||
PSDFixture::Buffer curves;
|
||||
curves.u8(0);
|
||||
@@ -354,12 +365,12 @@ void PSDReaderTests::levelsCurvesAndHueParse()
|
||||
QVERIFY(shift.kind == AdjustmentKind::hsv);
|
||||
const HueSaturationSettings settings = shift.hsvSettings.value();
|
||||
QVERIFY(settings.colorize);
|
||||
// Colorize's own values; the Master's go unread.
|
||||
QCOMPARE(settings.adjustments.at(ColorRange::master), (RangeAdjustment{30, -20, 10}));
|
||||
QCOMPARE(settings.adjustments.at(ColorRange::reds), (RangeAdjustment{-5, 40, 0}));
|
||||
QVERIFY(!settings.adjustments.contains(ColorRange::yellows));
|
||||
QVERIFY(!settings.adjustments.contains(ColorRange::reds));
|
||||
// `hue2` comes before `hue `.
|
||||
PSDFixture::Buffer plain;
|
||||
plain.u32(0);
|
||||
plain.bytes(QByteArray(16, '\0'));
|
||||
QVERIFY(!PSDAdjustments::parse({{"hue2", plain.data}, {"hue ", hue.data}}).value().hsvSettings.value().colorize);
|
||||
QVERIFY(!PSDAdjustments::parse({{"hue ", QByteArray("\0\0\1", 3)}}));
|
||||
QVERIFY(!PSDAdjustments::parse({{"expA", hue.data}}));
|
||||
|
||||
@@ -119,6 +119,10 @@ void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap()
|
||||
ProjectStore::save(twoLayers(), path);
|
||||
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"}));
|
||||
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"});
|
||||
// The preview and its folder, before the project's folder.
|
||||
faults.trace.clear();
|
||||
ProjectStore::save(twoLayers(), path, QuickLookImages{"preview"});
|
||||
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync file", "sync folder", "sync folder", "exchange", "sync folder"}));
|
||||
}
|
||||
|
||||
void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data()
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
#include "UI/ProjectTabs.h"
|
||||
#include <QMenu>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.3's strip: tabs drag to reorder; others gather.
|
||||
namespace {
|
||||
QToolButton &select(ProjectTabButton &button)
|
||||
{
|
||||
return *button.findChild<QToolButton *>(QStringLiteral("selectTab"));
|
||||
}
|
||||
|
||||
QStringList titles(const ProjectWorkspace &workspace)
|
||||
{
|
||||
QStringList result;
|
||||
for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs())
|
||||
result << tab->title();
|
||||
return result;
|
||||
}
|
||||
|
||||
ProjectTabButton &button(ProjectTabStrip &strip, const QString &title)
|
||||
{
|
||||
for (ProjectTabButton *button : strip.buttons()) {
|
||||
if (button->tab->title() == title)
|
||||
return *button;
|
||||
}
|
||||
throw std::runtime_error("no such tab");
|
||||
}
|
||||
|
||||
// Three tabs, Untitled to Untitled 3, the last in front.
|
||||
struct Strip {
|
||||
ProjectWorkspace workspace;
|
||||
ProjectTabStrip strip{workspace};
|
||||
Strip(int tabs = 3, int width = 1000)
|
||||
{
|
||||
for (int index = 1; index < tabs; ++index)
|
||||
workspace.newCanvas();
|
||||
strip.resize(width, 34);
|
||||
strip.show();
|
||||
if (!QTest::qWaitForWindowExposed(&strip))
|
||||
throw std::runtime_error("the strip never showed");
|
||||
}
|
||||
// QTest sends to a widget, not to the press's grab.
|
||||
QToolButton *pressed = nullptr;
|
||||
// Presses a tab; returns the point, in the strip.
|
||||
QPoint press(const QString &title)
|
||||
{
|
||||
ProjectTabButton &tab = button(strip, title);
|
||||
const QPoint point = tab.mapTo(&strip, QPoint(20, 14));
|
||||
pressed = &select(tab);
|
||||
QTest::mousePress(pressed, Qt::LeftButton, {}, pressed->mapFrom(&strip, point));
|
||||
return point;
|
||||
}
|
||||
void move(QPoint point) { QTest::mouseMove(pressed, pressed->mapFrom(&strip, point)); }
|
||||
// The release goes to the pressed title wherever it is.
|
||||
void release(const QString &title, QPoint point)
|
||||
{
|
||||
QToolButton &title_ = select(button(strip, title));
|
||||
QTest::mouseRelease(&title_, Qt::LeftButton, {}, title_.mapFrom(&strip, point));
|
||||
}
|
||||
};
|
||||
|
||||
// Every slide finished: tabs rest at their slots.
|
||||
bool settled(const ProjectTabStrip &strip)
|
||||
{
|
||||
return strip.findChildren<QPropertyAnimation *>().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectTabDragTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aDraggedTabFollowsThePointerAndLandsInTheGap();
|
||||
void aSmallOrUpwardMoveIsAClick();
|
||||
void aBusyProjectHoldsTheOrder();
|
||||
void aTabClosedMidDragEndsIt();
|
||||
void aLostReleaseGivesWayToTheNextDrag();
|
||||
void tabsThatDoNotFitGatherInAMenu();
|
||||
void theMenuSwitchesAndPinsTheChosenTab();
|
||||
void aBusyProjectOpensNoMenu();
|
||||
};
|
||||
|
||||
void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap()
|
||||
{
|
||||
Strip fixture;
|
||||
ProjectTabButton &first = button(fixture.strip, "Untitled"), &second = button(fixture.strip, "Untitled 2"),
|
||||
&third = button(fixture.strip, "Untitled 3");
|
||||
QCOMPARE(second.x(), first.width() + 6);
|
||||
const QPoint start = fixture.press("Untitled");
|
||||
// Two points stay a press; three drag, and select.
|
||||
fixture.move(start + QPoint(2, 0));
|
||||
QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id);
|
||||
fixture.move(start + QPoint(3, 0));
|
||||
QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id);
|
||||
fixture.move(start + QPoint(40, 0));
|
||||
QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id);
|
||||
QCOMPARE(first.x(), 40);
|
||||
// Chosen, it is set semibold: widths read now.
|
||||
const int w1 = first.width(), w2 = second.width(), w3 = third.width();
|
||||
// Nearest the second's compacted place still: nothing slides.
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(second.x(), w1 + 6);
|
||||
// Past the middle of the gap: the second slides left.
|
||||
fixture.move(start + QPoint(w2 / 2 + 10, 0));
|
||||
const auto *slide = second.findChild<QPropertyAnimation *>();
|
||||
QVERIFY(slide && slide->duration() == 150 && slide->easingCurve() == QEasingCurve::OutQuad);
|
||||
QCOMPARE(slide->endValue().toPoint(), QPoint(0, 3));
|
||||
QVERIFY(!first.findChild<QPropertyAnimation *>() && !third.findChild<QPropertyAnimation *>());
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
// The dragged tab lies over the one it crosses.
|
||||
QVERIFY(first.x() < second.x() + second.width());
|
||||
QWidget *hit = fixture.strip.childAt(first.x() + 2, 14);
|
||||
QVERIFY(hit == &first || first.isAncestorOf(hit));
|
||||
QCOMPARE(second.x(), 0);
|
||||
QCOMPARE(third.x(), w1 + 6 + w2 + 6);
|
||||
// Far right: held at the row's end, the others compacted.
|
||||
fixture.move(start + QPoint(5000, 0));
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
const int end = w1 + w2 + w3 + 12;
|
||||
QCOMPARE(first.x(), end - w1);
|
||||
QCOMPARE(third.x(), w2 + 6);
|
||||
// Far left: held at the row's start.
|
||||
fixture.move(start + QPoint(-5000, 0));
|
||||
QCOMPARE(first.x(), 0);
|
||||
fixture.move(start + QPoint(5000, 0));
|
||||
QVERIFY(first.isVisible() && first.y() == 3);
|
||||
fixture.release("Untitled", start + QPoint(5000, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled 3", "Untitled"}));
|
||||
QCOMPARE(fixture.workspace.selectedID(), first.tab->id);
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(second.x(), 0);
|
||||
QCOMPARE(third.x(), second.width() + 6);
|
||||
QCOMPARE(first.x(), third.x() + third.width() + 6);
|
||||
// The front tab, dragged one place left.
|
||||
const QPoint back = fixture.press("Untitled");
|
||||
fixture.move(back - QPoint(third.width() / 2 + 10, 0));
|
||||
fixture.release("Untitled", back - QPoint(third.width() / 2 + 10, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"}));
|
||||
// The first, dragged one place right.
|
||||
const QPoint right = fixture.press("Untitled 2");
|
||||
fixture.move(right + QPoint(first.width() / 2 + 10, 0));
|
||||
fixture.release("Untitled 2", right + QPoint(first.width() / 2 + 10, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"}));
|
||||
// Let go where it began: the order stays.
|
||||
const QPoint still = fixture.press("Untitled 3");
|
||||
fixture.move(still + QPoint(5, 0));
|
||||
fixture.release("Untitled 3", still + QPoint(5, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"}));
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(third.x(), second.width() + first.width() + 12);
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::aSmallOrUpwardMoveIsAClick()
|
||||
{
|
||||
Strip fixture;
|
||||
const QPoint start = fixture.press("Untitled");
|
||||
// Three points up, two across: no drag, no selection yet.
|
||||
fixture.move(start + QPoint(2, -3));
|
||||
QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id);
|
||||
QCOMPARE(button(fixture.strip, "Untitled").x(), 0);
|
||||
// Its release lost, a move under another button drags nothing.
|
||||
const QPoint far = fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0));
|
||||
QMouseEvent other(QEvent::MouseMove, QPointF(far), QPointF(fixture.pressed->mapToGlobal(far)), Qt::NoButton, Qt::RightButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(fixture.pressed, &other);
|
||||
QCOMPARE(button(fixture.strip, "Untitled").x(), 0);
|
||||
fixture.release("Untitled", start + QPoint(2, -3));
|
||||
// The click selects; the order stays.
|
||||
QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id);
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"}));
|
||||
// A busy project starts no drag and selects nothing.
|
||||
fixture.workspace.current().session.setIsProjectBusy(true);
|
||||
const QPoint busy = fixture.press("Untitled");
|
||||
fixture.move(busy + QPoint(200, 0));
|
||||
QCOMPARE(button(fixture.strip, "Untitled").x(), 0);
|
||||
fixture.release("Untitled", busy + QPoint(200, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"}));
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::aBusyProjectHoldsTheOrder()
|
||||
{
|
||||
Strip fixture;
|
||||
const QPoint start = fixture.press("Untitled");
|
||||
fixture.move(start + QPoint(5000, 0));
|
||||
QVERIFY(button(fixture.strip, "Untitled").x() > 100);
|
||||
// Busy by the drop: the tabs go back.
|
||||
fixture.workspace.current().session.setIsProjectBusy(true);
|
||||
fixture.release("Untitled", start + QPoint(5000, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"}));
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(button(fixture.strip, "Untitled").x(), 0);
|
||||
QCOMPARE(button(fixture.strip, "Untitled 2").x(), button(fixture.strip, "Untitled").width() + 6);
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::aTabClosedMidDragEndsIt()
|
||||
{
|
||||
Strip fixture;
|
||||
const QPoint start = fixture.press("Untitled 2");
|
||||
fixture.move(start - QPoint(5000, 0));
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QVERIFY(button(fixture.strip, "Untitled").x() > 0);
|
||||
fixture.workspace.removeTab(fixture.workspace.tabs()[1]->id);
|
||||
QCOMPARE(fixture.strip.buttons().size(), 2);
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(button(fixture.strip, "Untitled").x(), 0);
|
||||
QCOMPARE(button(fixture.strip, "Untitled 3").x(), button(fixture.strip, "Untitled").width() + 6);
|
||||
// Another drag works at once.
|
||||
const QPoint next = fixture.press("Untitled 3");
|
||||
fixture.move(next - QPoint(5000, 0));
|
||||
fixture.release("Untitled 3", next - QPoint(5000, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled"}));
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::aLostReleaseGivesWayToTheNextDrag()
|
||||
{
|
||||
Strip fixture;
|
||||
const QPoint first = fixture.press("Untitled");
|
||||
fixture.move(first + QPoint(5000, 0));
|
||||
// Its release never comes; another tab is dragged.
|
||||
const QPoint next = fixture.press("Untitled 3");
|
||||
fixture.move(next - QPoint(5000, 0));
|
||||
fixture.release("Untitled 3", next - QPoint(5000, 0));
|
||||
QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled", "Untitled 2"}));
|
||||
QTRY_VERIFY(settled(fixture.strip));
|
||||
QCOMPARE(button(fixture.strip, "Untitled 3").x(), 0);
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::tabsThatDoNotFitGatherInAMenu()
|
||||
{
|
||||
Strip fixture(12, 400);
|
||||
OverflowTabsPill &pill = *fixture.strip.pill();
|
||||
QVERIFY(pill.isVisible());
|
||||
QCOMPARE(pill.objectName(), QString("projectTabsOverflow"));
|
||||
QList<ProjectTabButton *> shown;
|
||||
for (ProjectTabButton *tab : fixture.strip.buttons()) {
|
||||
if (tab->isVisible())
|
||||
shown << tab;
|
||||
}
|
||||
const int hidden = 12 - int(shown.size());
|
||||
QVERIFY(hidden > 0 && shown.size() >= 2);
|
||||
QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden));
|
||||
QCOMPARE(pill.toolTip(), pill.text());
|
||||
QCOMPARE(pill.accessibleName(), pill.text());
|
||||
QCOMPARE(pill.geometry(), QRect(0, 3, int(OverflowTabsPill::pillWidth(hidden)), 28));
|
||||
// The right-most tabs stay, the front one last.
|
||||
QCOMPARE(shown.last()->tab->id, fixture.workspace.selectedID());
|
||||
QCOMPARE(shown.first()->tab->id, fixture.workspace.tabs()[size_t(hidden)]->id);
|
||||
QCOMPARE(shown.first()->x(), pill.width() + 6);
|
||||
QVERIFY(shown.last()->geometry().right() < 400);
|
||||
// Swift's measure: the label, 11, 4, 10, 11.
|
||||
QFont font;
|
||||
font.setPixelSize(12);
|
||||
font.setWeight(QFont::Medium);
|
||||
QCOMPARE(OverflowTabsPill::pillWidth(3), std::ceil(QFontMetricsF(font).horizontalAdvance("3 more tabs")) + 36);
|
||||
// Wide enough, every tab shows and the pill goes.
|
||||
fixture.strip.resize(3000, 34);
|
||||
QVERIFY(!pill.isVisible());
|
||||
for (ProjectTabButton *tab : fixture.strip.buttons())
|
||||
QVERIFY(tab->isVisible());
|
||||
QCOMPARE(fixture.strip.buttons().first()->x(), 0);
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::theMenuSwitchesAndPinsTheChosenTab()
|
||||
{
|
||||
Strip fixture(12, 400);
|
||||
OverflowTabsPill &pill = *fixture.strip.pill();
|
||||
fixture.workspace.tabs()[1]->session.createDocument(8, 8);
|
||||
QVERIFY(fixture.workspace.tabs()[1]->session.isModified());
|
||||
QTest::mousePress(&pill, Qt::LeftButton);
|
||||
auto *menu = pill.findChild<QMenu *>("projectTabsOverflowMenu");
|
||||
QVERIFY(menu && menu->isVisible());
|
||||
QStringList entries;
|
||||
for (const QAction *action : menu->actions())
|
||||
entries << action->text();
|
||||
const int hidden = int(entries.size());
|
||||
QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden));
|
||||
QCOMPARE(entries.mid(0, 3), (QStringList{"Untitled", "• Untitled 2", "Untitled 3"}));
|
||||
QCOMPARE(menu->mapToGlobal(QPoint(0, 0)), pill.mapToGlobal(QPoint(0, pill.height() + 4)));
|
||||
menu->actions()[1]->trigger();
|
||||
menu->close();
|
||||
QTest::mouseRelease(&pill, Qt::LeftButton);
|
||||
const QUuid chosen = fixture.workspace.tabs()[1]->id;
|
||||
QCOMPARE(fixture.workspace.selectedID(), chosen);
|
||||
// Pinned after the pill; the row stays inside.
|
||||
ProjectTabButton &pinned = button(fixture.strip, "Untitled 2");
|
||||
QVERIFY(pinned.isVisible());
|
||||
QCOMPARE(pinned.x(), pill.width() + 6);
|
||||
for (ProjectTabButton *tab : fixture.strip.buttons())
|
||||
QVERIFY(!tab->isVisible() || tab->geometry().right() < 400);
|
||||
QTRY_VERIFY(!pill.findChild<QMenu *>("projectTabsOverflowMenu"));
|
||||
}
|
||||
|
||||
void ProjectTabDragTests::aBusyProjectOpensNoMenu()
|
||||
{
|
||||
Strip fixture(12, 400);
|
||||
OverflowTabsPill &pill = *fixture.strip.pill();
|
||||
const QImage free = pill.grab().toImage();
|
||||
fixture.workspace.current().session.setIsProjectBusy(true);
|
||||
pill.update();
|
||||
// The label dims; the capsule stays.
|
||||
const QImage busy = pill.grab().toImage();
|
||||
QVERIFY(busy != free);
|
||||
QCOMPARE(busy.pixelColor(pill.width() / 2, 1), free.pixelColor(pill.width() / 2, 1));
|
||||
QTest::mouseClick(&pill, Qt::LeftButton);
|
||||
QVERIFY(!pill.findChild<QMenu *>("projectTabsOverflowMenu"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectTabDragTests)
|
||||
#include "ProjectTabDragTests.moc"
|
||||
@@ -0,0 +1,152 @@
|
||||
#include "UI/ProjectTabLayout.h"
|
||||
#include <QtTest>
|
||||
|
||||
// projectTabOverflow is geometry: made-up ids and widths.
|
||||
namespace {
|
||||
const QUuid a = QUuid::createUuid(), b = QUuid::createUuid(), c = QUuid::createUuid(), d = QUuid::createUuid(), e = QUuid::createUuid();
|
||||
|
||||
// Every tab 100 wide.
|
||||
std::map<QUuid, double> widths(const std::vector<QUuid> &ids)
|
||||
{
|
||||
std::map<QUuid, double> result;
|
||||
for (const QUuid &id : ids)
|
||||
result.insert({id, 100});
|
||||
return result;
|
||||
}
|
||||
|
||||
const std::function<double(int)> pill = [](int) { return 80.0; };
|
||||
|
||||
std::vector<QUuid> ids(const ProjectTabOverflow &layout)
|
||||
{
|
||||
std::vector<QUuid> result;
|
||||
for (const ProjectTabSlot &slot : layout.visible)
|
||||
result.push_back(slot.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<double> xs(const ProjectTabOverflow &layout)
|
||||
{
|
||||
std::vector<double> result;
|
||||
for (const ProjectTabSlot &slot : layout.visible)
|
||||
result.push_back(slot.x);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectTabLayoutTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void everythingFitsShowsAllTabsWithNoPill();
|
||||
void overflowHidesTheLeftmostTabsAndPillsThem();
|
||||
void selectedTabInTheHiddenSetIsPinnedRightOfThePill();
|
||||
void overflowLabelIsSingularForOneTab();
|
||||
void unmeasuredWidthShowsEverythingRatherThanGuessing();
|
||||
void aWiderSelectedTabStillFits();
|
||||
void thePillsWidthFollowsTheHiddenCount();
|
||||
};
|
||||
|
||||
void ProjectTabLayoutTests::everythingFitsShowsAllTabsWithNoPill()
|
||||
{
|
||||
const std::vector<QUuid> order{a, b, c};
|
||||
const ProjectTabOverflow result = projectTabOverflow(order, widths(order), c, 400, pill);
|
||||
QVERIFY(result.hiddenIDs.empty());
|
||||
QVERIFY(!result.pill);
|
||||
QVERIFY(ids(result) == order);
|
||||
QVERIFY((xs(result) == std::vector<double>{0, 106, 212}));
|
||||
QCOMPARE(result.contentWidth(), 312.0);
|
||||
// Exactly the room it needs still fits.
|
||||
QVERIFY(!projectTabOverflow(order, widths(order), c, 312, pill).pill);
|
||||
QVERIFY(projectTabOverflow(order, widths(order), c, 311.5, pill).pill);
|
||||
QVERIFY(projectTabOverflow({}, {}, c, 400, pill) == ProjectTabOverflow());
|
||||
QCOMPARE(ProjectTabOverflow().contentWidth(), 0.0);
|
||||
}
|
||||
|
||||
void ProjectTabLayoutTests::overflowHidesTheLeftmostTabsAndPillsThem()
|
||||
{
|
||||
const std::vector<QUuid> order{a, b, c, d, e};
|
||||
// The pill and two tabs: 292; a third needs 398.
|
||||
const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, pill);
|
||||
QVERIFY((result.hiddenIDs == std::vector<QUuid>{a, b, c}));
|
||||
QVERIFY((ids(result) == std::vector<QUuid>{d, e}));
|
||||
QVERIFY((result.pill.value() == ProjectTabPillSlot{0, 80}));
|
||||
QCOMPARE(result.visible.front().x, 86.0);
|
||||
QCOMPARE(result.visible.front().width, 100.0);
|
||||
QCOMPARE(result.contentWidth(), 292.0);
|
||||
// 292 exactly still holds two; a third at 398.
|
||||
QCOMPARE(projectTabOverflow(order, widths(order), e, 292, pill).visible.size(), size_t(2));
|
||||
QCOMPARE(projectTabOverflow(order, widths(order), e, 398, pill).visible.size(), size_t(3));
|
||||
// Too narrow for even one: one tab stays.
|
||||
QVERIFY((ids(projectTabOverflow(order, widths(order), e, 50, pill)) == std::vector<QUuid>{e}));
|
||||
}
|
||||
|
||||
void ProjectTabLayoutTests::selectedTabInTheHiddenSetIsPinnedRightOfThePill()
|
||||
{
|
||||
const std::vector<QUuid> order{a, b, c, d, e};
|
||||
// a takes the first slot; d, bumped, hides in order.
|
||||
const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 300, pill);
|
||||
QVERIFY((ids(result) == std::vector<QUuid>{a, e}));
|
||||
QVERIFY((result.hiddenIDs == std::vector<QUuid>{b, c, d}));
|
||||
QCOMPARE(result.pill.value().width, 80.0);
|
||||
QVERIFY((xs(result) == std::vector<double>{86, 192}));
|
||||
}
|
||||
|
||||
void ProjectTabLayoutTests::overflowLabelIsSingularForOneTab()
|
||||
{
|
||||
QCOMPARE(projectTabOverflowLabel(1), QString("1 more tab"));
|
||||
QCOMPARE(projectTabOverflowLabel(2), QString("2 more tabs"));
|
||||
QCOMPARE(projectTabOverflowLabel(11), QString("11 more tabs"));
|
||||
}
|
||||
|
||||
void ProjectTabLayoutTests::unmeasuredWidthShowsEverythingRatherThanGuessing()
|
||||
{
|
||||
const std::vector<QUuid> order{a, b, c, d, e};
|
||||
const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 0, pill);
|
||||
QVERIFY(result.hiddenIDs.empty());
|
||||
QVERIFY(!result.pill);
|
||||
QCOMPARE(result.visible.size(), size_t(5));
|
||||
}
|
||||
|
||||
// A long-titled hidden tab still leaves a row that fits.
|
||||
void ProjectTabLayoutTests::aWiderSelectedTabStillFits()
|
||||
{
|
||||
std::vector<QUuid> order;
|
||||
std::map<QUuid, double> sizes;
|
||||
for (int index = 0; index < 8; ++index) {
|
||||
order.push_back(QUuid::createUuid());
|
||||
sizes.insert({order.back(), 80});
|
||||
}
|
||||
sizes[order[0]] = 190;
|
||||
const ProjectTabOverflow layout = projectTabOverflow(order, sizes, order[0], 500, [](int) { return 90.0; });
|
||||
QVERIFY2(layout.contentWidth() <= 500, qPrintable(QString::number(layout.contentWidth())));
|
||||
QCOMPARE(layout.visible.front().id, order[0]);
|
||||
// Pill 90, 190, two of 80; three would make 522.
|
||||
QVERIFY((ids(layout) == std::vector<QUuid>{order[0], order[6], order[7]}));
|
||||
QVERIFY((layout.hiddenIDs == std::vector<QUuid>{order[1], order[2], order[3], order[4], order[5]}));
|
||||
QCOMPARE(layout.contentWidth(), 90 + 6 + 190 + 6 + 80 + 6 + 80.0);
|
||||
// Wide enough, it stands alone after the pill.
|
||||
const std::vector<QUuid> five{a, b, c, d, e};
|
||||
std::map<QUuid, double> wide = widths(five);
|
||||
wide[a] = 290;
|
||||
const ProjectTabOverflow alone = projectTabOverflow(five, wide, a, 400, pill);
|
||||
QVERIFY((ids(alone) == std::vector<QUuid>{a}));
|
||||
QVERIFY((alone.hiddenIDs == std::vector<QUuid>{b, c, d, e}));
|
||||
}
|
||||
|
||||
// The pill's width is asked by its hidden count.
|
||||
void ProjectTabLayoutTests::thePillsWidthFollowsTheHiddenCount()
|
||||
{
|
||||
const std::vector<QUuid> order{a, b, c, d, e};
|
||||
std::vector<int> asked;
|
||||
const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, [&](int hidden) {
|
||||
asked.push_back(hidden);
|
||||
return 50.0 + hidden;
|
||||
});
|
||||
// Three hidden fits (265); the last ask places it.
|
||||
QVERIFY((asked == std::vector<int>{0, 1, 2, 3, 3}));
|
||||
QVERIFY((ids(result) == std::vector<QUuid>{d, e}));
|
||||
QCOMPARE(result.pill.value().width, 53.0);
|
||||
QCOMPARE(result.visible.front().x, 59.0);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(ProjectTabLayoutTests)
|
||||
#include "ProjectTabLayoutTests.moc"
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "UI/ProjectTabs.h"
|
||||
#include <QFontMetricsF>
|
||||
#include <QScrollBar>
|
||||
#include <QToolButton>
|
||||
#include <QtTest>
|
||||
|
||||
@@ -26,7 +25,6 @@ class ProjectTabStripTests : public QObject {
|
||||
private slots:
|
||||
void eachTabIsAsWideAsItsTitle();
|
||||
void aTitleMovesOnlyTheTabsAfterIt();
|
||||
void onlyANewFrontTabScrollsIntoView();
|
||||
};
|
||||
|
||||
void ProjectTabStripTests::eachTabIsAsWideAsItsTitle()
|
||||
@@ -103,44 +101,5 @@ void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt()
|
||||
QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6);
|
||||
}
|
||||
|
||||
void ProjectTabStripTests::onlyANewFrontTabScrollsIntoView()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
for (int tab = 0; tab < 11; ++tab)
|
||||
workspace.newCanvas();
|
||||
ProjectTabStrip strip(workspace);
|
||||
strip.resize(300, 34);
|
||||
strip.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&strip));
|
||||
QScrollBar &bar = *strip.horizontalScrollBar();
|
||||
// Scrolled back, another tab's change keeps the offset.
|
||||
QTRY_VERIFY(bar.value() == bar.maximum() && bar.maximum() > 0);
|
||||
// Scrolled back by hand, another tab's change keeps the offset.
|
||||
bar.setValue(40);
|
||||
workspace.tabs()[3]->session.setProjectPath(QStringLiteral("/p/Renamed.comp"));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(bar.value(), 40);
|
||||
// The front tab turns unsaved: a dot, the offset kept.
|
||||
workspace.current().session.createDocument(8, 8);
|
||||
workspace.current().session.history.markSaved();
|
||||
QVERIFY(!workspace.current().session.isModified());
|
||||
workspace.current().session.addBlankLayer();
|
||||
QVERIFY(workspace.current().session.isModified());
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(select(*strip.buttons().last()).text().startsWith(QStringLiteral("●")));
|
||||
QCOMPARE(bar.value(), 40);
|
||||
// A tab closed behind is no new front tab either.
|
||||
const QUuid front = workspace.selectedID();
|
||||
workspace.removeTab(workspace.tabs()[5]->id);
|
||||
QCOMPARE(workspace.selectedID(), front);
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(bar.value(), 40);
|
||||
// Choosing another tab brings it in, as little as needed.
|
||||
workspace.select(workspace.tabs()[0]->id);
|
||||
QTRY_COMPARE(bar.value(), 0);
|
||||
workspace.select(workspace.tabs()[1]->id);
|
||||
QCOMPARE(bar.value(), 0);
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectTabStripTests)
|
||||
#include "ProjectTabStripTests.moc"
|
||||
@@ -42,6 +42,7 @@ private slots:
|
||||
void everyChangeIsAnnouncedAndRefusalsAreSilent();
|
||||
void aDropThatIsNoLayerIdIsIgnored();
|
||||
void anOpenFilterHoldsTheSwitch();
|
||||
void moveTabReordersWithoutTouchingSelectionOrDocuments();
|
||||
};
|
||||
|
||||
void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal()
|
||||
@@ -358,5 +359,36 @@ void ProjectWorkspaceTests::anOpenFilterHoldsTheSwitch()
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
const QUuid a = workspace.current().id, b = workspace.addTab(false).id, c = workspace.addTab(false).id;
|
||||
const auto order = [&] {
|
||||
std::vector<QUuid> ids;
|
||||
for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs())
|
||||
ids.push_back(tab->id);
|
||||
return ids;
|
||||
};
|
||||
QSignalSpy changed(&workspace, &ProjectWorkspace::changed);
|
||||
workspace.moveTab(c, 0);
|
||||
QVERIFY((order() == std::vector<QUuid>{c, a, b}));
|
||||
workspace.moveTab(a, 2);
|
||||
QVERIFY((order() == std::vector<QUuid>{c, b, a}));
|
||||
QCOMPARE(changed.size(), 2);
|
||||
// Out of range clamps to the ends.
|
||||
workspace.moveTab(c, 99);
|
||||
QVERIFY((order() == std::vector<QUuid>{b, a, c}));
|
||||
workspace.moveTab(c, -4);
|
||||
QVERIFY((order() == std::vector<QUuid>{c, b, a}));
|
||||
QCOMPARE(changed.size(), 4);
|
||||
// Where it is, or no tab: nothing, silently.
|
||||
workspace.moveTab(a, 2);
|
||||
workspace.moveTab(QUuid::createUuid(), 0);
|
||||
QVERIFY((order() == std::vector<QUuid>{c, b, a}));
|
||||
QCOMPARE(changed.size(), 4);
|
||||
// Chrome: the selection stays.
|
||||
QCOMPARE(workspace.selectedID(), c);
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectWorkspaceTests)
|
||||
#include "ProjectWorkspaceTests.moc"
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QPushButton>
|
||||
#include <QTemporaryDir>
|
||||
#include <QToolButton>
|
||||
#include <QToolBar>
|
||||
#include <QtTest>
|
||||
|
||||
// The window: tabs, the toolbar, closing and quitting.
|
||||
@@ -203,29 +204,27 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState()
|
||||
late.session.setProjectPath(QString("/x/Late.comp"));
|
||||
workspace.select(workspace.tabs()[0]->id);
|
||||
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"}));
|
||||
// More tabs than fit: the front one scrolls into view.
|
||||
// More tabs than fit: the rest gather under the pill.
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&window));
|
||||
for (int index = 0; index < 8; ++index)
|
||||
for (int index = 0; index < 12; ++index)
|
||||
workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index));
|
||||
const ProjectTabButton *last = strip.buttons().last();
|
||||
const auto wholeInView = [&](const ProjectTabButton *button) {
|
||||
return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size()));
|
||||
return button->isVisible() && strip.rect().contains(button->geometry());
|
||||
};
|
||||
// In view once laid out, as Swift posts its reveal.
|
||||
QVERIFY(last->tab->id == workspace.selectedID());
|
||||
QTRY_VERIFY(wholeInView(last));
|
||||
QVERIFY(strip.widget()->width() > strip.viewport()->width());
|
||||
QVERIFY(last->findChild<QToolButton *>("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0);
|
||||
// Since 1.2.4 a growing title keeps the offset.
|
||||
const int offset = strip.horizontalScrollBar()->value();
|
||||
QVERIFY(wholeInView(last));
|
||||
QVERIFY(strip.pill()->isVisible() && !strip.buttons().first()->isVisible());
|
||||
// The strip ends before the zoom buttons.
|
||||
const QWidget &fitButton = *window.findChild<QToolBar *>("toolbar")->widgetForAction(&action(window, "fitCanvas"));
|
||||
QVERIFY(strip.mapTo(&window, QPoint(strip.width(), 0)).x() <= fitButton.mapTo(&window, QPoint()).x());
|
||||
// A growing title keeps the front tab whole.
|
||||
workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp");
|
||||
QCoreApplication::processEvents();
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(last->width() > 180);
|
||||
QCOMPARE(strip.horizontalScrollBar()->value(), offset);
|
||||
QVERIFY(wholeInView(last));
|
||||
workspace.select(workspace.tabs()[0]->id);
|
||||
const ProjectTabButton *head = strip.buttons().first();
|
||||
QTRY_VERIFY(wholeInView(head));
|
||||
QVERIFY(wholeInView(head));
|
||||
}
|
||||
|
||||
void ProjectWorkspaceViewTests::tabButtonsSelectAndClose()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "DialogDesk.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include <QBuffer>
|
||||
#include <QColorSpace>
|
||||
#include <QImageReader>
|
||||
#include <QImageWriter>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// The project's QuickLook/Preview.jpg: written on save, ignored on load.
|
||||
namespace {
|
||||
const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden;
|
||||
|
||||
// One layer stretched over a canvas of this size.
|
||||
ProjectSnapshot stretched(int width, int height, const QImage &pixels)
|
||||
{
|
||||
const QUuid id = QUuid::createUuid();
|
||||
const ProjectLayerRecord layer{.id = id, .name = "Stretched", .isVisible = true,
|
||||
.transform = {.origin = {0, 0}, .size = {double(width), double(height)}}, .imageFile = uuidString(id) + ".png"};
|
||||
return {.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = id, .layers = {layer}},
|
||||
.images = {{id, asset(pixels, "source")}}};
|
||||
}
|
||||
|
||||
QImage decoded(const QByteArray &data)
|
||||
{
|
||||
QImage image = QImage::fromData(data, "jpeg");
|
||||
if (image.isNull())
|
||||
throw std::runtime_error("the preview is no JPEG");
|
||||
return image;
|
||||
}
|
||||
|
||||
QSize previewSize(int width, int height)
|
||||
{
|
||||
QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied);
|
||||
empty.fill(0);
|
||||
return decoded(ImageExporter::quickLookImages(stretched(width, height, empty)).value().preview).size();
|
||||
}
|
||||
}
|
||||
|
||||
class QuickLookPreviewTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theStoreWritesThePreviewAndLoadingIgnoresIt();
|
||||
void thePreviewIsTheImageOnWhiteAtQualityEighty();
|
||||
void theLongSideIsAt1024AndNothingGrows();
|
||||
void pastFiftyMegapixelsThereIsNone();
|
||||
void aCanvasThatCannotRenderSavesWithoutOne();
|
||||
void everySaveWritesIt();
|
||||
};
|
||||
|
||||
void QuickLookPreviewTests::theStoreWritesThePreviewAndLoadingIgnoresIt()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = root.filePath("Previewed.comp");
|
||||
const ProjectSnapshot snapshot = twoLayers();
|
||||
ProjectStore::save(snapshot, path, QuickLookImages{"not even a JPEG"});
|
||||
QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json", "QuickLook"}));
|
||||
QCOMPARE(QDir(path + "/QuickLook").entryList(everything), QStringList{"Preview.jpg"});
|
||||
QCOMPARE(contents(path + "/QuickLook/Preview.jpg"), QByteArray("not even a JPEG"));
|
||||
// Its bytes are never read.
|
||||
QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded());
|
||||
// Saved without one, the project has none.
|
||||
ProjectStore::save(snapshot, path);
|
||||
QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json"}));
|
||||
QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded());
|
||||
}
|
||||
|
||||
void QuickLookPreviewTests::thePreviewIsTheImageOnWhiteAtQualityEighty()
|
||||
{
|
||||
// Half opaque red, half clear, over 2048 by 1024.
|
||||
const ProjectSnapshot snapshot = stretched(2048, 1024, halfRed());
|
||||
const QByteArray preview = ImageExporter::quickLookImages(snapshot).value().preview;
|
||||
const QImage image = decoded(preview);
|
||||
QCOMPARE(image.size(), QSize(1024, 512));
|
||||
QVERIFY(qAbs(image.pixelColor(200, 256).red() - 255) <= 3 && image.pixelColor(200, 256).green() <= 3);
|
||||
QCOMPARE(image.pixelColor(800, 256), QColor(Qt::white));
|
||||
// Byte for byte: smooth scaling, white, quality 80, 72 dpi.
|
||||
const QImage scaled = ImageExporter::render(snapshot).image.scaled(1024, 512, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
QImage flattened(1024, 512, QImage::Format_RGB888);
|
||||
flattened.fill(Qt::white);
|
||||
QPainter(&flattened).drawImage(QRectF(flattened.rect()), scaled);
|
||||
flattened.setDotsPerMeterX(2835);
|
||||
flattened.setDotsPerMeterY(2835);
|
||||
flattened.setColorSpace(QColorSpace::SRgb);
|
||||
QByteArray expected;
|
||||
QBuffer buffer(&expected);
|
||||
QImageWriter writer(&buffer, "jpeg");
|
||||
writer.setQuality(80);
|
||||
QVERIFY(writer.write(flattened));
|
||||
QCOMPARE(preview, expected);
|
||||
}
|
||||
|
||||
void QuickLookPreviewTests::theLongSideIsAt1024AndNothingGrows()
|
||||
{
|
||||
QCOMPARE(previewSize(300, 200), QSize(300, 200));
|
||||
QCOMPARE(previewSize(1024, 7), QSize(1024, 7));
|
||||
QCOMPARE(previewSize(1025, 1025), QSize(1024, 1024));
|
||||
// 1001 × 1024 / 3000 is 341.67: rounded, not cut.
|
||||
QCOMPARE(previewSize(3000, 1001), QSize(1024, 342));
|
||||
QCOMPARE(previewSize(10, 5000), QSize(2, 1024));
|
||||
// A sliver keeps a pixel.
|
||||
QCOMPARE(previewSize(1, 3000), QSize(1, 1024));
|
||||
QCOMPARE(previewSize(3000, 1), QSize(1024, 1));
|
||||
}
|
||||
|
||||
void QuickLookPreviewTests::pastFiftyMegapixelsThereIsNone()
|
||||
{
|
||||
QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied);
|
||||
empty.fill(0);
|
||||
QVERIFY(!ImageExporter::quickLookImages(stretched(7072, 7071, empty)));
|
||||
QCOMPARE(decoded(ImageExporter::quickLookImages(stretched(10'000, 5'000, empty)).value().preview).size(), QSize(1024, 512));
|
||||
}
|
||||
|
||||
void QuickLookPreviewTests::aCanvasThatCannotRenderSavesWithoutOne()
|
||||
{
|
||||
ProjectSnapshot snapshot = twoLayers();
|
||||
snapshot.images.clear();
|
||||
QTest::ignoreMessage(QtWarningMsg, "no Quick Look preview: An image inside the project is missing or damaged. The current document has not been replaced.");
|
||||
QVERIFY(!ImageExporter::quickLookImages(snapshot));
|
||||
}
|
||||
|
||||
void QuickLookPreviewTests::everySaveWritesIt()
|
||||
{
|
||||
QTemporaryDir folder;
|
||||
EditorSession session;
|
||||
session.createDocument(8, 6);
|
||||
QImage image(8, 6, QImage::Format_RGBA8888_Premultiplied);
|
||||
image.fill(Qt::blue);
|
||||
session.insert(ImportedImage(image, QImage(), "Blue"));
|
||||
ProjectController controller(session);
|
||||
DialogDesk desk;
|
||||
desk.replies = {folder.filePath("Saved")};
|
||||
bool saved = false;
|
||||
controller.save(false, [&](bool done) { saved = done; });
|
||||
QTRY_VERIFY(saved);
|
||||
const QImage preview = decoded(contents(folder.filePath("Saved.comp/QuickLook/Preview.jpg")));
|
||||
QCOMPARE(preview.size(), QSize(8, 6));
|
||||
QVERIFY(preview.pixelColor(4, 3).blue() >= 250 && preview.pixelColor(4, 3).red() <= 5);
|
||||
}
|
||||
|
||||
QTEST_MAIN(QuickLookPreviewTests)
|
||||
#include "QuickLookPreviewTests.moc"
|
||||
@@ -0,0 +1,246 @@
|
||||
#include "DialogDesk.h"
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "SelectionFixtures.h"
|
||||
#include "UI/ProjectWorkspaceView.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.4.4: quit applies canvas edits and cancels dialogs.
|
||||
namespace {
|
||||
ImportedImage filled(const QColor &colour)
|
||||
{
|
||||
QImage pixels(100, 20, QImage::Format_RGBA8888_Premultiplied);
|
||||
pixels.fill(colour);
|
||||
return ImportedImage(pixels, pixels, QStringLiteral("Fixture"));
|
||||
}
|
||||
|
||||
// Swift's await: true once the call's callback has run.
|
||||
bool awaited(const std::function<void(std::function<void()>)> &call)
|
||||
{
|
||||
bool done = false;
|
||||
call([&done] { done = true; });
|
||||
return QTest::qWaitFor([&done] { return done; }, 10'000);
|
||||
}
|
||||
|
||||
std::optional<bool> answer(ProjectWorkspace &workspace)
|
||||
{
|
||||
std::optional<bool> result;
|
||||
workspace.confirmQuit([&](bool value) { result = value; });
|
||||
return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt;
|
||||
}
|
||||
|
||||
// Swift's test: a gradient waiting for Apply.
|
||||
void pendGradient(EditorSession &session)
|
||||
{
|
||||
session.createDocument(100, 20);
|
||||
session.addBlankLayer();
|
||||
session.selectTool(NavigationTool::gradient);
|
||||
session.beginGradient(QPointF(0, 10));
|
||||
session.moveGradient(std::nullopt, QPointF(100, 10));
|
||||
session.endGradientDrag();
|
||||
}
|
||||
|
||||
ImageIdentity identity(const EditorSession &session)
|
||||
{
|
||||
return session.activeLayer().value().asset.value().identity();
|
||||
}
|
||||
}
|
||||
|
||||
class QuitPendingEditsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void quittingAppliesAPendingGradient();
|
||||
void quittingAppliesAPixelMove();
|
||||
void quittingCancelsAnOpenDialog();
|
||||
void quittingCancelsTheOtherDialogs();
|
||||
void quittingCancelsAnAdjustmentsEditor();
|
||||
void aWorkspaceGoneMidSettleCallsNothing();
|
||||
void closingTheWindowAppliesTheGradientThenAsks();
|
||||
void aBusyProjectHoldsTheQuit();
|
||||
void aQuitWhileManagingIsRefusedAtOnce();
|
||||
};
|
||||
|
||||
void QuitPendingEditsTests::quittingAppliesAPendingGradient()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.gradientEdit());
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Gradient"));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingAppliesAPixelMove()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
session.applySelection(rectPath(QRectF(10, 5, 10, 10)), SelectionMode::replace, "Select");
|
||||
QVERIFY(session.beginPixelMove());
|
||||
session.movePixels(QSizeF(30, 0));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.pixelMove());
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Move Pixels"));
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(40, 5, 10, 10));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsAnOpenDialog()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
const ImageIdentity original = identity(session);
|
||||
const int count = session.history.undoCount();
|
||||
const std::vector<std::function<void()>> opens{[&] { session.beginFilter(FilterKind::gaussianBlur); },
|
||||
[&] { session.beginHueSaturation(); }, [&] { session.beginLevels(); }};
|
||||
for (const std::function<void()> &open : opens) {
|
||||
open();
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
QVERIFY(identity(session) == original);
|
||||
}
|
||||
QVERIFY(!session.filterEdit() && !session.hueSaturation() && !session.levels());
|
||||
QCOMPARE(session.history.undoCount(), count);
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsTheOtherDialogs()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
const QPainterPath before = rectPath(QRectF(10, 5, 10, 10));
|
||||
session.applySelection(before, SelectionMode::replace, "Select");
|
||||
const PaletteColor foreground = session.foregroundColor();
|
||||
const auto settle = [&] { return awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }); };
|
||||
// Color Range: the selection goes back as it was.
|
||||
session.beginColorRange();
|
||||
QVERIFY(session.colorRange() && !workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.colorRange());
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), before.boundingRect());
|
||||
// Expand, Contract or Feather: its panel goes.
|
||||
session.setSelectionAmountOperation(SelectionAmountOperation::feather);
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.selectionAmountOperation());
|
||||
// The colour picker: closed, its colour not taken.
|
||||
session.openColorPicker(false);
|
||||
session.setColorPickerHSB(PickerHSB(120, 1, 1));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.colorPicker());
|
||||
QVERIFY(session.foregroundColor() == foreground);
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsAnAdjustmentsEditor()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(Qt::white));
|
||||
session.addAdjustment(AdjustmentKind::exposure);
|
||||
const QUuid id = session.adjustmentEditingID().value();
|
||||
const LayerAdjustment made = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { session.beginAdjustmentEditing(id, std::move(done)); }));
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
settings.exposure.exposure = 2;
|
||||
session.updateFilter(settings, true);
|
||||
QVERIFY(!(session.activeLayer().value().adjustment.value() == made));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.adjustmentEditingID() && !session.adjustmentOriginal() && !session.filterEdit());
|
||||
QVERIFY(session.activeLayer().value().adjustment.value() == made);
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aWorkspaceGoneMidSettleCallsNothing()
|
||||
{
|
||||
auto workspace = std::make_unique<ProjectWorkspace>();
|
||||
const std::shared_ptr<ProjectTab> tab = workspace->tabs().front();
|
||||
pendGradient(tab->session);
|
||||
bool called = false;
|
||||
workspace->settlePendingEdits([&called] { called = true; });
|
||||
workspace.reset();
|
||||
// The tab lives on and its gradient lands; no answer.
|
||||
QTRY_VERIFY(!tab->session.gradientEdit());
|
||||
QTest::qWait(100);
|
||||
QVERIFY(!called);
|
||||
QCOMPARE(tab->session.history.undoName(), QString("Gradient"));
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::closingTheWindowAppliesTheGradientThenAsks()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
ProjectWorkspaceView view(workspace);
|
||||
view.show();
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
session.history.markSaved();
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Cancel"};
|
||||
// The gradient is a step by the time it asks.
|
||||
desk.note = [&] { return QStringLiteral("pending=%1 step=%2").arg(session.gradientEdit().has_value()).arg(session.history.undoName()); };
|
||||
view.close();
|
||||
QTRY_COMPARE(desk.seen.size(), 1);
|
||||
QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Untitled?") && desk.seen[0].endsWith("|pending=0 step=Gradient"),
|
||||
qPrintable(desk.seen[0]));
|
||||
QTRY_VERIFY(!workspace.isManaging());
|
||||
QVERIFY(view.isVisible());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aBusyProjectHoldsTheQuit()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
session.setIsProjectBusy(true);
|
||||
DialogDesk desk;
|
||||
QTest::ignoreMessage(QtWarningMsg, "Quit refused: the project is still busy");
|
||||
QCOMPARE(answer(workspace), std::optional(false));
|
||||
// Nothing asked, nothing applied: the gradient waits.
|
||||
QVERIFY(desk.seen.isEmpty());
|
||||
QVERIFY(session.gradientEdit());
|
||||
QVERIFY(!workspace.isManaging());
|
||||
session.setIsProjectBusy(false);
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aQuitWhileManagingIsRefusedAtOnce()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Cancel"};
|
||||
std::optional<bool> second;
|
||||
bool settled = false;
|
||||
bool answered = false;
|
||||
// While the first quit asks, a second is refused unasked.
|
||||
desk.note = [&] {
|
||||
session.setSelectionAmountOperation(SelectionAmountOperation::feather);
|
||||
workspace.confirmQuit([&](bool value) { second = value; });
|
||||
answered = QTest::qWaitFor([&] { return second.has_value(); }, 5000);
|
||||
settled = !session.selectionAmountOperation();
|
||||
session.setSelectionAmountOperation(std::nullopt);
|
||||
return QString();
|
||||
};
|
||||
QCOMPARE(answer(workspace), std::optional(false));
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
QVERIFY(answered);
|
||||
QCOMPARE(second, std::optional(false));
|
||||
QVERIFY(!settled);
|
||||
}
|
||||
|
||||
QTEST_MAIN(QuitPendingEditsTests)
|
||||
#include "QuitPendingEditsTests.moc"
|
||||
@@ -0,0 +1,393 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/Dither.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QPainter>
|
||||
#include <QSlider>
|
||||
#include <QtTest>
|
||||
#include <numeric>
|
||||
|
||||
// Swift 1.4.4's Dither › Scanlines (CRT), on every core.
|
||||
namespace {
|
||||
QImage filled(int width, int height, QColor colour)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
image.fill(colour);
|
||||
return image;
|
||||
}
|
||||
|
||||
DitherSettings scanlines(double spacing, double glow = 0)
|
||||
{
|
||||
DitherSettings settings;
|
||||
settings.style = DitherStyle::scanlines;
|
||||
settings.lineSpacing = spacing;
|
||||
settings.glow = glow;
|
||||
return settings;
|
||||
}
|
||||
|
||||
// One channel of one column, top to bottom.
|
||||
std::vector<int> column(const QImage &image, int x, int channel = 0)
|
||||
{
|
||||
std::vector<int> values;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
values.push_back(image.constScanLine(y)[x * 4 + channel]);
|
||||
return values;
|
||||
}
|
||||
|
||||
std::vector<int> row(const QImage &image, int y, int channel = 0)
|
||||
{
|
||||
std::vector<int> values;
|
||||
for (int x = 0; x < image.width(); ++x)
|
||||
values.push_back(image.constScanLine(y)[x * 4 + channel]);
|
||||
return values;
|
||||
}
|
||||
|
||||
// Swift's test: one column of a flat gray's render.
|
||||
std::vector<int> gray(int level, double spacing, double glow = 0)
|
||||
{
|
||||
return column(scanlines(spacing, glow).apply(filled(16, 32, QColor(level, level, level))), 5);
|
||||
}
|
||||
|
||||
std::vector<int> repeated(const std::vector<int> &band, int times)
|
||||
{
|
||||
std::vector<int> all;
|
||||
for (int each = 0; each < times; ++each)
|
||||
all.insert(all.end(), band.begin(), band.end());
|
||||
return all;
|
||||
}
|
||||
|
||||
struct Shown {
|
||||
EditorSession session;
|
||||
std::unique_ptr<FilterSheet> sheet;
|
||||
Shown()
|
||||
{
|
||||
session.createDocument(40, 30);
|
||||
const QImage image = filled(40, 30, QColor(200, 120, 40));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
|
||||
session.beginFilter(FilterKind::dither);
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
settings.dither.style = DitherStyle::scanlines;
|
||||
session.updateFilter(settings, true);
|
||||
sheet = std::make_unique<FilterSheet>(session);
|
||||
sheet->show();
|
||||
}
|
||||
const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; }
|
||||
// The shown rows in the column's order, with their help.
|
||||
QStringList rows() const
|
||||
{
|
||||
QStringList titles;
|
||||
QLayout *column = sheet->layout();
|
||||
for (int index = 0; index < column->count(); ++index) {
|
||||
QWidget *each = column->itemAt(index)->widget();
|
||||
if (!each || each->isHidden())
|
||||
continue;
|
||||
if (auto *box = qobject_cast<QCheckBox *>(each); box && box->text() != QLatin1String("Preview"))
|
||||
titles << box->text();
|
||||
for (QLabel *label : each->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) {
|
||||
if (label->buddy())
|
||||
titles << label->text() + u'|' + each->toolTip();
|
||||
}
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class ScanlinesTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void scanlinesAreLinesOfLightThatBloomWithBrightness();
|
||||
void glowLightsBetweenTheLines();
|
||||
void dotsBreakTheLinesIntoBeads();
|
||||
void wobbleMovesLinesSideways();
|
||||
void theStyleDrawsAtFullResolutionWithoutMarks();
|
||||
void normalizingClampsTheFourSettings();
|
||||
void eachLineIsTheAverageOfItsRows();
|
||||
void theScreenIsTheDarkColourOrBlack();
|
||||
void clearPixelsStayClear();
|
||||
void everyBandOfATallImageIsDrawn();
|
||||
void bandsDrawAsOneBandDoes();
|
||||
void theGlowIsTheBlurredLightAtItsAmount();
|
||||
void theGlowNeverPassesAlpha();
|
||||
void theSheetShowsScanlinesRows();
|
||||
void theRowsWriteTheirSettings();
|
||||
};
|
||||
|
||||
void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness()
|
||||
{
|
||||
const std::vector<int> white = gray(255, 8), dim = gray(89, 8), black = gray(0, 8);
|
||||
for (int band = 0; band < 4; ++band) {
|
||||
const std::vector<int> rows(white.begin() + band * 8, white.begin() + band * 8 + 8);
|
||||
QVERIFY(rows[3] == 255 && rows[4] == 255);
|
||||
QVERIFY(std::any_of(rows.begin(), rows.end(), [](int value) { return value < 40; }));
|
||||
}
|
||||
QVERIFY(std::accumulate(dim.begin(), dim.end(), 0) * 2 < std::accumulate(white.begin(), white.end(), 0));
|
||||
QVERIFY(std::all_of(black.begin(), black.end(), [](int value) { return value == 0; }));
|
||||
// The beam's half height, 2.8 white, about 1.98 at 0.35.
|
||||
QCOMPARE(white, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 4));
|
||||
QCOMPARE(dim, repeated({0, 0, 118, 120, 120, 118, 0, 0}, 4));
|
||||
}
|
||||
|
||||
void ScanlinesTests::glowLightsBetweenTheLines()
|
||||
{
|
||||
const std::vector<int> plain = gray(255, 8), glowing = gray(255, 8, 100);
|
||||
QVERIFY2(glowing[0] > plain[0] + 40, qPrintable(QString::number(glowing[0])));
|
||||
}
|
||||
|
||||
void ScanlinesTests::dotsBreakTheLinesIntoBeads()
|
||||
{
|
||||
const QImage white = filled(64, 16, Qt::white);
|
||||
DitherSettings settings = scanlines(8);
|
||||
QCOMPARE(row(settings.apply(white), 4), std::vector<int>(64, 255));
|
||||
settings.dots = 100;
|
||||
// Each bead lit at its middle, dark at its ends.
|
||||
const std::vector<int> lit = row(settings.apply(white), 4);
|
||||
QCOMPARE(std::vector<int>(lit.begin(), lit.begin() + 8), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
|
||||
QCOMPARE(std::vector<int>(lit.begin() + 8, lit.begin() + 16), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
|
||||
// Half dots: the bead ends dim off the middle.
|
||||
settings.dots = 50;
|
||||
QCOMPARE(row(settings.apply(white), 1)[0], 85);
|
||||
}
|
||||
|
||||
void ScanlinesTests::wobbleMovesLinesSideways()
|
||||
{
|
||||
QImage edge = filled(64, 64, Qt::black);
|
||||
QPainter(&edge).fillRect(QRect(32, 0, 32, 64), Qt::white);
|
||||
const auto edges = [&edge](double wobble) {
|
||||
DitherSettings settings = scanlines(8);
|
||||
settings.wobble = wobble;
|
||||
const QImage result = settings.apply(edge);
|
||||
std::vector<int> found;
|
||||
for (int line = 0; line < 8; ++line) {
|
||||
const std::vector<int> middle = row(result, line * 8 + 4);
|
||||
found.push_back(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin()));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
QCOMPARE(edges(0), std::vector<int>(8, 32));
|
||||
// Each line's shift: 12 times the two waves, rounded.
|
||||
QCOMPARE(edges(12), (std::vector<int>{35, 36, 35, 41, 44, 37, 32, 34}));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theStyleDrawsAtFullResolutionWithoutMarks()
|
||||
{
|
||||
QVERIFY(!usesPixelSize(DitherStyle::scanlines) && !usesPixelSize(DitherStyle::ascii) && usesPixelSize(DitherStyle::patterns));
|
||||
QVERIFY(!drawsMarks(DitherStyle::scanlines) && drawsMarks(DitherStyle::ascii));
|
||||
QVERIFY(!hasTones(DitherStyle::scanlines) && !isHalftone(DitherStyle::scanlines) && ditherGroup(DitherStyle::scanlines) == 3);
|
||||
// A pixel size changes nothing: no chunks, no dots.
|
||||
const QImage image = filled(20, 24, QColor(180, 90, 30));
|
||||
DitherSettings settings = scanlines(4);
|
||||
const QImage plain = settings.apply(image);
|
||||
settings.pixelSize = 5;
|
||||
settings.pixelShape = DitherPixelShape::dot;
|
||||
QCOMPARE(settings.apply(image), plain);
|
||||
// The cell is the line spacing, not the cell size.
|
||||
settings.cellSize = 32;
|
||||
QCOMPARE(settings.apply(image), plain);
|
||||
}
|
||||
|
||||
void ScanlinesTests::normalizingClampsTheFourSettings()
|
||||
{
|
||||
DitherSettings settings;
|
||||
QCOMPARE(settings.lineSpacing, 4.0);
|
||||
QCOMPARE(settings.glow, 35.0);
|
||||
QCOMPARE(settings.dots, 0.0);
|
||||
QCOMPARE(settings.wobble, 0.0);
|
||||
settings.lineSpacing = 1.4;
|
||||
settings.glow = 150;
|
||||
settings.dots = -5;
|
||||
settings.wobble = 3.5;
|
||||
DitherSettings result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 2.0);
|
||||
QCOMPARE(result.glow, 100.0);
|
||||
QCOMPARE(result.dots, 0.0);
|
||||
QCOMPARE(result.wobble, 3.5);
|
||||
settings.lineSpacing = 40;
|
||||
settings.glow = -1;
|
||||
settings.dots = 120;
|
||||
settings.wobble = 70;
|
||||
result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 32.0);
|
||||
QCOMPARE(result.glow, 0.0);
|
||||
QCOMPARE(result.dots, 100.0);
|
||||
QCOMPARE(result.wobble, 64.0);
|
||||
// Rounded within; not a number takes the default.
|
||||
settings.lineSpacing = 5.5;
|
||||
settings.glow = std::nan("");
|
||||
settings.dots = std::nan("");
|
||||
settings.wobble = std::nan("");
|
||||
result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 6.0);
|
||||
QCOMPARE(result.glow, 35.0);
|
||||
QCOMPARE(result.dots, 0.0);
|
||||
QCOMPARE(result.wobble, 0.0);
|
||||
settings.lineSpacing = std::nan("");
|
||||
QCOMPARE(settings.normalized().lineSpacing, 4.0);
|
||||
// Glow and dots keep their fractions.
|
||||
settings.glow = 12.5;
|
||||
settings.dots = 33.3;
|
||||
QCOMPARE(settings.normalized().glow, 12.5);
|
||||
QCOMPARE(settings.normalized().dots, 33.3);
|
||||
}
|
||||
|
||||
void ScanlinesTests::eachLineIsTheAverageOfItsRows()
|
||||
{
|
||||
// Rows of white and black: each line lights at half.
|
||||
QImage stripes = filled(8, 16, Qt::black);
|
||||
for (int y = 0; y < 16; y += 2)
|
||||
QPainter(&stripes).fillRect(QRect(0, y, 8, 1), Qt::white);
|
||||
QCOMPARE(column(scanlines(8).apply(stripes), 3), repeated({0, 37, 172, 172, 172, 172, 37, 0}, 2));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theScreenIsTheDarkColourOrBlack()
|
||||
{
|
||||
const QImage image = filled(8, 8, QColor(255, 128, 0));
|
||||
DitherSettings settings = scanlines(8);
|
||||
settings.colors = DitherColors::twoColors;
|
||||
settings.dark = AdjustmentColor(0, 0, 1);
|
||||
settings.light = AdjustmentColor(1, 1, 0);
|
||||
const QImage two = settings.apply(image);
|
||||
// Off the line the dark colour; on it, the mix.
|
||||
QCOMPARE(two.pixelColor(2, 0), QColor(0, 0, 255));
|
||||
QCOMPARE(two.pixelColor(2, 4), QColor(197, 197, 147));
|
||||
settings.colors = DitherColors::original;
|
||||
const QImage original = settings.apply(image);
|
||||
QCOMPARE(original.pixelColor(2, 0), QColor(0, 0, 0));
|
||||
QCOMPARE(original.pixelColor(2, 4), QColor(255, 173, 0));
|
||||
}
|
||||
|
||||
void ScanlinesTests::clearPixelsStayClear()
|
||||
{
|
||||
QImage image = filled(8, 16, Qt::white);
|
||||
QPainter painter(&image);
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Source);
|
||||
painter.fillRect(QRect(0, 0, 8, 8), Qt::transparent);
|
||||
painter.end();
|
||||
const QImage result = scanlines(8, 100).apply(image);
|
||||
QCOMPARE(column(result, 2, 3), (std::vector<int>{0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255}));
|
||||
QCOMPARE(column(result, 2, 0)[4], 0);
|
||||
}
|
||||
|
||||
void ScanlinesTests::everyBandOfATallImageIsDrawn()
|
||||
{
|
||||
// 150 two-row lines: 32 bands of 5, two empty.
|
||||
const QImage image = filled(3, 300, Qt::white);
|
||||
QCOMPARE(column(scanlines(2).apply(image), 1), repeated({241, 241}, 150));
|
||||
// 70 lines of 8: bands of 3, the last one.
|
||||
const std::vector<int> tall = column(scanlines(8).apply(filled(3, 560, Qt::white)), 1);
|
||||
QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70));
|
||||
}
|
||||
|
||||
void ScanlinesTests::bandsDrawAsOneBandDoes()
|
||||
{
|
||||
// One band of 62 rows, 32 of 300: tops agree.
|
||||
QImage noise = BrushRaster::context(7, 300, false);
|
||||
for (int y = 0; y < 300; ++y)
|
||||
for (int x = 0; x < 7; ++x)
|
||||
noise.setPixelColor(x, y, QColor((x * 37 + y * 11) % 256, (x * 91 + y * 53) % 256, (y * 29) % 256, 128 + (x + y) % 128));
|
||||
DitherSettings settings = scanlines(2);
|
||||
settings.colors = DitherColors::original;
|
||||
settings.dots = 50;
|
||||
settings.wobble = 5;
|
||||
const QImage whole = settings.apply(noise), one = settings.apply(noise.copy(0, 0, 7, 62));
|
||||
QCOMPARE(whole.copy(0, 0, 7, 62), one);
|
||||
}
|
||||
|
||||
void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount()
|
||||
{
|
||||
QImage image = filled(64, 160, Qt::black);
|
||||
QPainter(&image).fillRect(QRect(0, 0, 32, 40), Qt::white);
|
||||
const QImage lines = scanlines(8).apply(image);
|
||||
// Swift's first glow: the lines blurred whole, sigma 27.
|
||||
const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true);
|
||||
for (const double glow : {20.0, 100.0}) {
|
||||
const QImage result = scanlines(8, glow).apply(image);
|
||||
const float amount = float(glow / 100 * 2.5);
|
||||
int worst = 0;
|
||||
for (int y = 0; y < 160; ++y) {
|
||||
for (int x = 0; x < 64; ++x) {
|
||||
const int plain = lines.constScanLine(y)[x * 4], light = blurred.constScanLine(y)[x * 4];
|
||||
const int expected = int(std::lround(std::min(255.0f, float(plain) + float(light) * amount)));
|
||||
worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - expected));
|
||||
}
|
||||
}
|
||||
// Three levels of the shrink's blur, times 2.5.
|
||||
QVERIFY2(worst <= 8, qPrintable(QString::number(worst)));
|
||||
}
|
||||
// Below the light, at amount 0.5, it fades.
|
||||
const std::vector<int> fading = column(scanlines(8, 20).apply(image), 16);
|
||||
QVERIFY(fading[48] > 20 && fading[48] < fading[40] && fading[120] < 3);
|
||||
}
|
||||
|
||||
void ScanlinesTests::theGlowNeverPassesAlpha()
|
||||
{
|
||||
QImage image = BrushRaster::context(16, 32, false);
|
||||
image.fill(QColor(255, 255, 255, 128));
|
||||
const QImage result = scanlines(4, 100).apply(image);
|
||||
for (int y = 0; y < 32; ++y) {
|
||||
for (int x = 0; x < 16; ++x) {
|
||||
const uchar *at = result.constScanLine(y) + x * 4;
|
||||
QCOMPARE(int(at[3]), 128);
|
||||
QVERIFY(at[0] <= 128 && at[1] <= 128 && at[2] <= 128);
|
||||
}
|
||||
}
|
||||
QCOMPARE(int(result.constScanLine(0)[0]), 128);
|
||||
}
|
||||
|
||||
void ScanlinesTests::theSheetShowsScanlinesRows()
|
||||
{
|
||||
Shown shown;
|
||||
QCOMPARE(shown.rows(), (QStringList{"Style|", "Line Spacing|How far apart the screen's lines are",
|
||||
"Glow|Light blooming around the lines, like a CRT's phosphors", "Dots|Break the lines into glowing beads",
|
||||
"Wobble|Make the lines waver sideways down the screen, like a CRT losing sync",
|
||||
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|"}));
|
||||
// Each slider's travel reaches its range's ends.
|
||||
const std::tuple<const char *, double DitherSettings::*, double, double> sliders[] = {
|
||||
{"lineSpacingSlider", &DitherSettings::lineSpacing, 2, 32}, {"glowSlider", &DitherSettings::glow, 0, 100},
|
||||
{"dotsSlider", &DitherSettings::dots, 0, 100}, {"wobbleSlider", &DitherSettings::wobble, 0, 64}};
|
||||
for (const auto &[name, setting, low, high] : sliders) {
|
||||
QSlider *slider = shown.sheet->findChild<QSlider *>(QString::fromLatin1(name));
|
||||
QVERIFY2(slider, name);
|
||||
slider->setValue(slider->maximum());
|
||||
QCOMPARE(shown.dither().*setting, high);
|
||||
slider->setValue(slider->minimum());
|
||||
QCOMPARE(shown.dither().*setting, low);
|
||||
}
|
||||
// Pixel Size and Pixel Shape stay away under Scanlines.
|
||||
FilterSettings settings = shown.session.filterEdit().value().settings;
|
||||
settings.dither.pixelSize = 4;
|
||||
shown.session.updateFilter(settings, true);
|
||||
QVERIFY(!shown.rows().join(',').contains("Pixel"));
|
||||
settings.dither.style = DitherStyle::bayer4;
|
||||
shown.session.updateFilter(settings, true);
|
||||
QVERIFY(shown.rows().contains("Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"));
|
||||
QVERIFY(!shown.rows().join(',').contains("Glow"));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theRowsWriteTheirSettings()
|
||||
{
|
||||
Shown shown;
|
||||
const std::tuple<const char *, double DitherSettings::*, const char *, double> rows[] = {
|
||||
{"lineSpacingField", &DitherSettings::lineSpacing, "12", 12}, {"glowField", &DitherSettings::glow, "60", 60},
|
||||
{"dotsField", &DitherSettings::dots, "25", 25}, {"wobbleField", &DitherSettings::wobble, "9", 9}};
|
||||
for (const auto &[name, setting, typed, expected] : rows) {
|
||||
const DitherSettings before = shown.dither();
|
||||
QLineEdit *field = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name));
|
||||
QVERIFY2(field, name);
|
||||
field->setFocus();
|
||||
field->selectAll();
|
||||
QTest::keyClicks(field, QString::fromLatin1(typed));
|
||||
QTest::keyClick(field, Qt::Key_Return);
|
||||
DitherSettings wanted = before;
|
||||
wanted.*setting = expected;
|
||||
QVERIFY2(shown.dither() == wanted, name);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(ScanlinesTests)
|
||||
#include "ScanlinesTests.moc"
|
||||
@@ -55,8 +55,8 @@ void invert(EditorSession &session)
|
||||
class SelectionEditTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void maskButtonAddsWhiteMaskOrHidesTheSelection();
|
||||
void layerMenuMasksUseTheSelection();
|
||||
void maskButtonAddsWhiteMaskOrRevealsTheSelection();
|
||||
void hidingMasksUseTheSelection();
|
||||
void maskFromSelectionLinesUpOnScaledLayers();
|
||||
void deletingWithTheMaskTargetedRemovesOnlyTheMask();
|
||||
void invertKeepsTransparencyStaysInSelectionAndWorksOnMasks();
|
||||
@@ -67,7 +67,7 @@ private slots:
|
||||
void aPaintedAssetThatCannotFlattenReportsAndFreesTheProject();
|
||||
};
|
||||
|
||||
void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection()
|
||||
void SelectionEditTests::maskButtonAddsWhiteMaskOrRevealsTheSelection()
|
||||
{
|
||||
const auto session = editSession();
|
||||
session->insert(filledRed());
|
||||
@@ -79,12 +79,12 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection()
|
||||
QVERIFY(!session->activeLayer().value().mask.has_value());
|
||||
select(*session, QRectF(20, 10, 30, 20));
|
||||
session->addMask();
|
||||
QCOMPARE(session->history.undoName(), QString("Add Mask from Selection"));
|
||||
QCOMPARE(session->history.undoName(), QString("Reveal Selection"));
|
||||
QVERIFY(!session->selection().has_value() && session->isMaskSelected());
|
||||
const QImage result = render(*session);
|
||||
// The selected area is black, hidden; the rest white.
|
||||
QCOMPARE(pixel(result, 30, 20)[3], 0);
|
||||
QCOMPARE(pixel(result, 5, 5)[3], 255);
|
||||
// The selected area is white, visible; the rest black.
|
||||
QCOMPARE(pixel(result, 30, 20)[3], 255);
|
||||
QCOMPARE(pixel(result, 5, 5)[3], 0);
|
||||
session->undo();
|
||||
QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value());
|
||||
// A layer with a mask takes no second one.
|
||||
@@ -95,18 +95,18 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection()
|
||||
QCOMPARE(session->history.undoCount(), count + 1);
|
||||
}
|
||||
|
||||
void SelectionEditTests::layerMenuMasksUseTheSelection()
|
||||
void SelectionEditTests::hidingMasksUseTheSelection()
|
||||
{
|
||||
const auto session = editSession();
|
||||
session->insert(filledRed());
|
||||
select(*session, QRectF(20, 10, 30, 20));
|
||||
session->addMask(false);
|
||||
QCOMPARE(session->history.undoName(), QString("Add Mask from Selection"));
|
||||
QCOMPARE(session->history.undoName(), QString("Hide Selection"));
|
||||
QVERIFY(!session->selection().has_value() && session->isMaskSelected());
|
||||
const QImage result = render(*session);
|
||||
// The selected area is white, visible; the rest black.
|
||||
QCOMPARE(pixel(result, 30, 20)[3], 255);
|
||||
QCOMPARE(pixel(result, 5, 5)[3], 0);
|
||||
// The selected area is black, hidden; the rest white.
|
||||
QCOMPARE(pixel(result, 30, 20)[3], 0);
|
||||
QCOMPARE(pixel(result, 5, 5)[3], 255);
|
||||
session->undo();
|
||||
QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value());
|
||||
session->deselect();
|
||||
@@ -128,7 +128,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers()
|
||||
record(snapshot, id).transform.size = QSizeF(100, 100);
|
||||
});
|
||||
select(*session, QRectF(0, 0, 50, 50));
|
||||
session->addMask();
|
||||
session->addMask(false);
|
||||
// The mask uses the layer's pixel grid.
|
||||
QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(50, 50));
|
||||
const QImage result = render(*session);
|
||||
@@ -139,7 +139,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers()
|
||||
session->undo();
|
||||
session->addBlankLayer();
|
||||
select(*session, QRectF(10, 10, 20, 20));
|
||||
session->addMask();
|
||||
session->addMask(false);
|
||||
QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(100, 100));
|
||||
QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(15)[15]), 0);
|
||||
QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255);
|
||||
@@ -153,7 +153,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers()
|
||||
session->undo();
|
||||
session->setSelectionAntialiased(soft);
|
||||
session->applySelection(triangle, SelectionMode::replace, "Select");
|
||||
session->addMask();
|
||||
session->addMask(false);
|
||||
const QImage edge = session->activeLayer().value().mask.value().asset.image();
|
||||
int partial = 0;
|
||||
for (int x = 0; x < 100; ++x) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Rendering/TiledLayerRenderer.h"
|
||||
#include <QPainter>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's sharper canvas: upright layers at 1:1 copy across.
|
||||
namespace {
|
||||
// Black and white columns, opaque, 100 by 4.
|
||||
QImage columns()
|
||||
{
|
||||
QImage image = BrushRaster::context(100, 4, false);
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
for (int x = 0; x < 100; ++x)
|
||||
image.setPixel(x, y, x % 2 ? qRgba(255, 255, 255, 255) : qRgba(0, 0, 0, 255));
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
using Draw = std::function<void(const LayerTransform &, QPainter &)>;
|
||||
|
||||
// Pixels under the layer neither black nor white.
|
||||
int blurred(double width, const Draw &draw)
|
||||
{
|
||||
QImage surface = BrushRaster::context(120, 4, false);
|
||||
QPainter painter(&surface);
|
||||
draw(LayerTransform{.origin = {0, 0}, .size = {width, 4}}, painter);
|
||||
painter.end();
|
||||
int count = 0;
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
for (int x = 0; x < 100; ++x)
|
||||
count += qRed(surface.pixel(x, y)) % 255 != 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
class SharpCanvasTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void anUprightLayerPixelForPixelTakesNoFilter();
|
||||
void plainMaskedAndTiledLayersCopyAcross();
|
||||
};
|
||||
|
||||
void SharpCanvasTests::anUprightLayerPixelForPixelTakesNoFilter()
|
||||
{
|
||||
for (LayerSampling sampling : {LayerSampling::smooth, LayerSampling::high}) {
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 1.0009, true), InterpolationQuality::none);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 0.9991, true), InterpolationQuality::none);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 0.9989, true), InterpolationQuality::low);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 1, false), InterpolationQuality::low);
|
||||
}
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0011, true), InterpolationQuality::high);
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0009, false), InterpolationQuality::high);
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::nearest, 1.5, true), InterpolationQuality::none);
|
||||
}
|
||||
|
||||
void SharpCanvasTests::plainMaskedAndTiledLayersCopyAcross()
|
||||
{
|
||||
const QImage image = columns();
|
||||
QImage white = BrushRaster::context(100, 4, true);
|
||||
white.fill(255);
|
||||
const auto raster = std::make_shared<const RasterSnapshot>(100, 4, image, QRectF(0, 0, 100, 4), std::vector<BrushPatch>{});
|
||||
const std::vector<Draw> draws{
|
||||
[&](const LayerTransform &transform, QPainter &painter) { LayerRenderer::draw(image, transform, transform.center(), painter); },
|
||||
[&](const LayerTransform &transform, QPainter &painter) {
|
||||
LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = white});
|
||||
},
|
||||
[&](const LayerTransform &transform, QPainter &painter) {
|
||||
TiledLayerRenderer::drawRaster(raster, transform, transform.center(), painter);
|
||||
},
|
||||
};
|
||||
for (const Draw &draw : draws) {
|
||||
// A twentieth of a pixel over 100: within tolerance, crisp.
|
||||
QCOMPARE(blurred(100.05, draw), 0);
|
||||
// A fifth: filtered as before.
|
||||
QVERIFY(blurred(100.2, draw) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(SharpCanvasTests)
|
||||
#include "SharpCanvasTests.moc"
|
||||
@@ -102,8 +102,8 @@ void SheetTeardownTests::theDocumentSheetsGoQuietly()
|
||||
{
|
||||
const CanvasDocument canvas{120, 80};
|
||||
typeThenDestroy(std::make_unique<ImageSizeSheet>(canvas, [](std::optional<ImageSizeOptions>) {}), QStringLiteral("60"));
|
||||
typeThenDestroy(std::make_unique<CanvasSizeSheet>(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}),
|
||||
QStringLiteral("60"));
|
||||
EditorSession session;
|
||||
typeThenDestroy(std::make_unique<CanvasSizeSheet>(canvas, session, [](std::optional<CanvasSizeOptions>) {}), QStringLiteral("60"));
|
||||
}
|
||||
|
||||
void SheetTeardownTests::aCameraRawRowGoesQuietly()
|
||||
|
||||
@@ -138,6 +138,7 @@ compositor_test(LiveMaskDeleteTests)
|
||||
compositor_test(LiveMaskEditTests)
|
||||
compositor_test(LiveMaskSessionTests)
|
||||
compositor_test(LiveMaskTests)
|
||||
compositor_test(MaskAloneTests)
|
||||
compositor_test(MaskPlacementTests)
|
||||
compositor_test(MaskSignalTests)
|
||||
compositor_test(MaskTracingTests)
|
||||
@@ -170,6 +171,10 @@ compositor_test(ProjectReceiveTests)
|
||||
compositor_test(ProjectReplaceTests)
|
||||
compositor_test(ProjectWorkspaceFlowTests)
|
||||
compositor_test(QuitWhileEditingTextTests)
|
||||
compositor_test(QuitPendingEditsTests)
|
||||
compositor_test(ScanlinesTests)
|
||||
compositor_test(CameraRawCurveTests)
|
||||
compositor_test(CameraRawCurveGraphTests)
|
||||
compositor_test(OpenRecentTests)
|
||||
compositor_test(ColoredSliderTests)
|
||||
compositor_test(SheetTeardownTests)
|
||||
@@ -202,11 +207,14 @@ compositor_test(ProjectWorkspaceSignalTests)
|
||||
compositor_test(ProjectWorkspaceTests)
|
||||
compositor_test(ProjectWorkspaceViewTests)
|
||||
compositor_test(ProjectTabStripTests)
|
||||
compositor_test(ProjectTabDragTests)
|
||||
compositor_test(ProjectTabLayoutTests)
|
||||
compositor_test(KeyboardZoomTests)
|
||||
# It starts the real app beside itself.
|
||||
add_dependencies(ProjectWorkspaceViewTests omaphoto)
|
||||
compositor_test(ProjectSessionTests)
|
||||
compositor_test(ProjectTests)
|
||||
compositor_test(QuickLookPreviewTests)
|
||||
compositor_test(RasterSnapshotSessionTests)
|
||||
compositor_test(RasterSnapshotTests)
|
||||
compositor_test(SessionGateTests)
|
||||
@@ -268,6 +276,15 @@ compositor_test(PSDVectorTests)
|
||||
compositor_test(PSDReaderTests)
|
||||
compositor_test(PSDReaderLayerTests)
|
||||
compositor_test(PSDImportTests)
|
||||
compositor_test(PSDAdjustmentTests)
|
||||
compositor_test(SharpCanvasTests)
|
||||
compositor_test(AdjustmentBandsTests)
|
||||
compositor_test(LayerOrderTests)
|
||||
compositor_test(BlendingTests)
|
||||
compositor_test(AdjustmentCanvasTests)
|
||||
compositor_test(CanvasEditContractTests)
|
||||
# It checks the bands against one pass of the kernels.
|
||||
target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(RawImporterTests)
|
||||
compositor_test(RawDevelopTests)
|
||||
compositor_test(ToolDefaultsTests)
|
||||
|
||||
Reference in new issue
Block a user