Crop ratios and selection start, Vignette on empty layers, text colour previews (12.10c); mutation runs use 12 cores

This commit is contained in:
雷电芽衣 committed 2026-09-28 21:03:16 -04:00
1 parent 7a873eb9c7
commit ffba1753c6
27 files changed
+555 -55

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ void CropControlsTests::theBarShowsTheFrameAndItsRatio()
QStringList choices;
for (int index = 0; index < ratio.count(); ++index)
choices << ratio.itemText(index);
QCOMPARE(choices, (QStringList{"Free", "Original", "1:1", "4:3", "16:9"}));
QCOMPARE(choices, (QStringList{"Free", "Original", "1:1", "4:3", "3:4", "16:9", "9:16"}));
QCOMPARE(ratio.currentText(), QString("Free"));
// Labelled as Swift's picker, the two 170 points wide.
const QList<QLabel *> labels = bar.findChildren<QLabel *>();
+66 -1
View File
@@ -1,5 +1,6 @@
#include "CanvasFixtures.h"
#include "CropFixtures.h"
#include "SelectionFixtures.h"
#include "Document/Crop.h"
#include "IO/ImageExporter.h"
#include <QPainter>
@@ -11,6 +12,7 @@
class CropTests : public QObject {
Q_OBJECT
private slots:
void theFrameStartsAtTheSelection();
void dragGeometrySupportsReverseRatioMoveAndEveryHandle();
void cropTranslatesWithoutResamplingAndUndoRestoresBounds();
void sameSizeOffsetCropAndExpansionUseExactBounds();
@@ -279,7 +281,7 @@ void CropTests::ratiosReshapeTheFrameAboutItsMiddle()
session.selectTool(NavigationTool::crop);
session.setCropRect(QRectF(10, 20, 120, 60));
const std::pair<const char *, std::optional<double>> ratios[] = {
{"Original", 1.5}, {"1:1", 1.0}, {"4:3", 4.0 / 3}, {"16:9", 16.0 / 9}, {"Free", std::nullopt}};
{"Original", 1.5}, {"1:1", 1.0}, {"4:3", 4.0 / 3}, {"3:4", 0.75}, {"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"Free", std::nullopt}};
for (const auto &[choice, ratio] : ratios) {
session.setCropRatioChoice(QString::fromLatin1(choice));
QCOMPARE(session.cropRatio(), ratio);
@@ -316,5 +318,68 @@ void CropTests::ratiosReshapeTheFrameAboutItsMiddle()
QCOMPARE(empty.visibleCropRect(), std::nullopt);
}
void CropTests::theFrameStartsAtTheSelection()
{
// Swift 1.2.5 (5ece82b): C, then Return, crops to the selection.
EditorSession session;
session.createDocument(300, 200);
session.applySelection(rectPath(QRectF(10.3, 20.6, 50.2, 30)), SelectionMode::replace, QStringLiteral("Select"));
session.selectTool(NavigationTool::crop);
// Rounded out to whole pixels.
QCOMPARE(session.cropRect(), std::optional(QRectF(10, 20, 51, 31)));
QCOMPARE(session.cropRatioChoice(), QString("Free"));
// A frame already drawn stays as it is.
session.setCropRect(QRectF(0, 0, 40, 40));
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 40, 40)));
session.cancelCrop();
// Past the canvas, the frame keeps to it.
session.selectTool(NavigationTool::move);
session.applySelection(rectPath(QRectF(280, 190, 50, 50)), SelectionMode::replace, QStringLiteral("Select"));
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(280, 190, 20, 10)));
session.cancelCrop();
// Nudged partly off the canvas, the frame keeps to it.
session.selectTool(NavigationTool::move);
session.applySelection(rectPath(QRectF(270, 20, 20, 20)), SelectionMode::replace, QStringLiteral("Select"));
session.nudgeSelection(20, 0);
QCOMPARE(session.selection().value().path.controlPointRect(), QRectF(290, 20, 20, 20));
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(290, 20, 10, 20)));
session.cancelCrop();
// Wholly off it, the frame is the canvas.
session.selectTool(NavigationTool::move);
session.nudgeSelection(20, 0);
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
session.cancelCrop();
// An empty selection starts at the whole canvas.
session.selectTool(NavigationTool::move);
session.applySelection(rectPath(QRectF(10, 10, 10, 10)), SelectionMode::replace, QStringLiteral("Select"));
session.applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, QStringLiteral("Subtract"));
QVERIFY(session.selection().value().isEmpty());
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
session.cancelCrop();
// An empty line between rows would round to a row.
session.selectTool(NavigationTool::move);
QPainterPath line(QPointF(10, 5.5));
line.lineTo(20, 5.5);
session.setSelection(DocumentSelection{line}, QStringLiteral("Select"));
QVERIFY(session.selection().value().isEmpty());
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
session.cancelCrop();
// A turned ellipse: its curve, not its control points.
session.selectTool(NavigationTool::move);
QPainterPath ellipse;
ellipse.addEllipse(QRectF(60, 60, 120, 40));
const QTransform turn = QTransform::fromTranslate(120, 80).rotate(45).translate(-120, -80);
session.setSelection(DocumentSelection{turn.map(ellipse)}, QStringLiteral("Select"));
QVERIFY(session.selection().value().path.controlPointRect().width() > 95);
session.selectTool(NavigationTool::crop);
QCOMPARE(session.cropRect(), std::optional(QRectF(75, 35, 90, 90)));
}
QTEST_GUILESS_MAIN(CropTests)
#include "CropTests.moc"
+3 -2
View File
@@ -158,7 +158,8 @@ void FinishingFilterTests::theKernelsTakeTheSettingsAtTheScale()
FilterSettings vignette{.vignetteAmount = 70, .vignetteColor = AdjustmentColor(0.2, 0.9, 0.4), .vignetteMidpoint = 30,
.vignetteRoundness = -40, .vignetteFeather = 20, .vignetteHighlights = 60};
QImage expected = source.copy();
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 70, 30, -40, 20, 60, 0.2, 0.9, 0.4);
// The layer's own pixels frame it; clear ones stay clear.
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 0, 0, 40, 30, 0, 70, 30, -40, 20, 60, 0.2, 0.9, 0.4);
QCOMPARE(run(FilterKind::vignette, source, vignette, 3), expected);
// Tonal Contrast: its base blurred at radius times scale.
const FilterSettings tonal{.tonalAmount = 80, .tonalRadius = 3, .tonalShadows = -30, .tonalMidtones = 70, .tonalHighlights = 45};
@@ -181,7 +182,7 @@ void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds()
{
const FilterSettings defaults;
QVERIFY(defaults.vignetteAmount == 35 && defaults.vignetteColor == AdjustmentColor(0, 0, 0) && defaults.vignetteMidpoint == 50);
QVERIFY(defaults.vignetteRoundness == 0 && defaults.vignetteFeather == 60 && defaults.vignetteHighlights == 25);
QVERIFY(defaults.vignetteRoundness == 100 && defaults.vignetteFeather == 60 && defaults.vignetteHighlights == 25);
QVERIFY(defaults.bloomAmount == 40 && defaults.bloomRadius == 24);
QVERIFY(defaults.tonalAmount == 50 && defaults.tonalRadius == 16 && defaults.tonalShadows == 40 && defaults.tonalMidtones == 60);
QCOMPARE(defaults.tonalHighlights, 30.0);
+2
View File
@@ -257,6 +257,8 @@ compositor_test(FilterControlsTests)
compositor_test(FinishingFilterTests)
# It checks the filters against their kernels.
target_include_directories(FinishingFilterTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(VignetteCanvasTests)
target_include_directories(VignetteCanvasTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(FloatingSelectionTests)
compositor_test(CropTests)
compositor_test(GuideTests)
+15
View File
@@ -43,6 +43,7 @@ private slots:
void theButtonsFollowTheDraft();
void theBarRestsWithoutADocumentOrWhileBusy();
void aTextRowShowsAndOpensItsText();
void thePickersColourPreviewsOnOpenText();
};
void TypeControlsTests::theBarShowsTheStyleItEdits()
@@ -455,5 +456,19 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText()
QVERIFY(!session.renamingLayerID());
}
void TypeControlsTests::thePickersColourPreviewsOnOpenText()
{
// Swift's onChange: each working colour reaches the draft.
Bar shown;
shown.session.beginText(QPointF(20, 30));
shown.session.openTextColorPicker();
PickerHSB hsb = shown.session.colorPicker().value().hsb;
hsb.setRGB(PaletteColor{1, 0, 0});
shown.session.setColorPickerHSB(hsb);
QCOMPARE(shown.session.textDraft().value().style.red, 1.0);
shown.session.closeColorPicker(false);
QCOMPARE(shown.session.textDraft().value().style.red, 0.0);
}
QTEST_MAIN(TypeControlsTests)
#include "TypeControlsTests.moc"
+100
View File
@@ -14,6 +14,14 @@ std::unique_ptr<EditorSession> makeSession()
return session;
}
// Swift writes `colorPicker.hsb` in place.
void pick(EditorSession &session, const PaletteColor &color)
{
PickerHSB hsb = session.colorPicker().value().hsb;
hsb.setRGB(color);
session.setColorPickerHSB(hsb);
}
// Swift writes `session.textDraft?.style.content` in place.
void type(EditorSession &session, const QString &content)
{
@@ -50,6 +58,8 @@ private slots:
void paragraphBoxAndToolSwitchCommitEditableText();
void emptyNewParagraphIsDiscarded();
void invalidAndStaleDraftsDoNotChangeDocument();
void textColorPickerPreviewsAndRestoresDraft();
void theForegroundPickerPreviewsOpenTextToo();
};
void TypeToolTests::createEditCancelAndUndo()
@@ -217,5 +227,95 @@ void TypeToolTests::invalidAndStaleDraftsDoNotChangeDocument()
QCOMPARE(session->document().value().layers.size(), size_t(1));
}
void TypeToolTests::textColorPickerPreviewsAndRestoresDraft()
{
const auto session = makeSession();
session->beginText(QPointF(30, 40));
const LayerTextStyle original = session->textDraft().value().style;
session->openTextColorPicker();
pick(*session, PaletteColor{1, 0, 0});
session->previewTextColor();
QCOMPARE(session->textDraft().value().style.red, 1.0);
QCOMPARE(session->textDraft().value().style.green, 0.0);
QCOMPARE(session->foregroundColor(), PaletteColor::black());
session->closeColorPicker(false);
QCOMPARE(session->textDraft().value().style, original);
QCOMPARE(session->foregroundColor(), PaletteColor::black());
session->openTextColorPicker();
pick(*session, PaletteColor{0, 0, 1});
session->previewTextColor();
session->closeColorPicker(true);
QCOMPARE(session->textDraft().value().style.blue, 1.0);
QCOMPARE(session->foregroundColor(), (PaletteColor{0, 0, 1}));
// With no text open, Cancel leaves the next text's colour.
session->setTextDraft(std::nullopt);
session->openTextColorPicker();
pick(*session, PaletteColor{1, 0, 0});
session->closeColorPicker(true);
session->setForegroundColor(PaletteColor{0, 1, 0});
session->openTextColorPicker();
session->closeColorPicker(false);
QCOMPARE(session->textDefaults().red, 1.0);
QCOMPARE(session->textDefaults().green, 0.0);
}
void TypeToolTests::theForegroundPickerPreviewsOpenTextToo()
{
// Swift 1.2.5 (4e5f4ec): open text follows the foreground picker.
const auto session = makeSession();
session->beginText(QPointF(30, 40));
const auto paint = [&](const PaletteColor &color) {
session->changeTextStyle([&](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
};
const auto colour = [&] {
const LayerTextStyle style = session->textDraft().value().style;
return PaletteColor{style.red, style.green, style.blue};
};
// Blue text, a black foreground: Cancel restores the text's own.
paint(PaletteColor{0, 0, 1});
session->openColorPicker(false);
pick(*session, PaletteColor{0, 1, 0});
session->previewTextColor();
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
session->closeColorPicker(false);
QCOMPARE(colour(), (PaletteColor{0, 0, 1}));
QCOMPARE(session->foregroundColor(), PaletteColor::black());
// Another draft set meanwhile is neither previewed nor restored.
session->openColorPicker(false);
TextDraft other = session->textDraft().value();
other.id = QUuid::createUuid();
session->setTextDraft(other);
paint(PaletteColor{1, 0, 0});
pick(*session, PaletteColor{0, 1, 0});
session->previewTextColor();
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
session->closeColorPicker(false);
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
// OK keeps the colour, and the foreground with it.
session->openColorPicker(false);
pick(*session, PaletteColor{0, 1, 0});
session->previewTextColor();
session->closeColorPicker(true);
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
QCOMPARE(session->foregroundColor(), (PaletteColor{0, 1, 0}));
// The background picker leaves text alone.
session->openColorPicker(true);
pick(*session, PaletteColor{1, 0, 0});
session->previewTextColor();
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
session->closeColorPicker(false);
// The Type bar's picker: a later draft keeps its colour.
session->openTextColorPicker();
other.id = QUuid::createUuid();
session->setTextDraft(other);
paint(PaletteColor{1, 0, 0});
session->closeColorPicker(false);
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
}
QTEST_MAIN(TypeToolTests)
#include "TypeToolTests.moc"
+190
View File
@@ -0,0 +1,190 @@
#include "CanvasFixtures.h"
#include "Document/Filters.h"
#include "MenuFixtures.h"
#include "SessionFixtures.h"
#include <QPainter>
extern "C" {
#include "AdjustPixels.h"
}
// Swift 1.2.5: Vignette round by default, painting an empty layer.
namespace {
bool prepared(EditorSession &session)
{
return QTest::qWaitFor([&] { return session.filterEdit() && !session.filterEdit().value().preparing; }, 20'000);
}
bool committed(EditorSession &session)
{
bool done = false;
session.commitFilter([&done] { done = true; });
return QTest::qWaitFor([&done] { return done; }, 20'000);
}
}
class VignetteCanvasTests : public QObject {
Q_OBJECT
private slots:
void clearPixelsTakeTheColourTowardTheFramesEdge();
void theCanvasFramesALayerThatSitsInsideIt();
void onlyVignettePaintsAnEmptyLayer();
void anEmptyLayerTakesTheVignetteAcrossTheCanvas();
void theCanvasShowsThePreviewOnAnEmptyLayer();
void aLayerWithPixelsKeepsItsOwnFrameAndClear();
void aSmallEmptyLayerGrowsToTheCanvas();
void theFrameIsReadTopDown();
};
void VignetteCanvasTests::clearPixelsTakeTheColourTowardTheFramesEdge()
{
QImage clear = BrushRaster::context(41, 41, false);
// Without fillsClear, clear pixels stay as they are.
QImage kept = clear.copy();
adjust_colored_vignette(kept.bits(), 41, 41, size_t(kept.bytesPerLine()), 0, 0, 41, 41, 0, 100, 50, 100, 60, 25, 1, 0, 0);
QCOMPARE(kept, clear);
adjust_colored_vignette(clear.bits(), 41, 41, size_t(clear.bytesPerLine()), 0, 0, 41, 41, 1, 100, 50, 100, 60, 25, 1, 0, 0);
// The middle stays clear; the corner is red, near opaque.
QCOMPARE(clear.pixel(20, 20), qRgba(0, 0, 0, 0));
const QRgb corner = clear.pixel(0, 0);
QVERIFY(qAlpha(corner) > 240 && qRed(corner) == qAlpha(corner) && qGreen(corner) == 0 && qBlue(corner) == 0);
}
void VignetteCanvasTests::theCanvasFramesALayerThatSitsInsideIt()
{
// A 20-pixel layer at (10, 10) of a 40-pixel canvas.
const QImage clear = BrushRaster::context(20, 20, false);
FilterSettings settings;
settings.vignetteAmount = 100;
FilterJob job{FilterKind::vignette, clear, settings, 1, std::nullopt, QTransform::fromTranslate(10, 10)};
job.canvas = QRectF(0, 0, 40, 40);
QImage expected = clear.copy();
adjust_colored_vignette(expected.bits(), 20, 20, size_t(expected.bytesPerLine()), -10, -10, 40, 40, 1, 100, 50, 100, 60, 25, 0, 0, 0);
QCOMPARE(PixelFilter::run(job), expected);
// Its own corner sits inside the frame: fainter.
QImage framedAlone = clear.copy();
adjust_colored_vignette(framedAlone.bits(), 20, 20, size_t(framedAlone.bytesPerLine()), 0, 0, 20, 20, 1, 100, 50, 100, 60, 25, 0, 0, 0);
QVERIFY(qAlpha(expected.pixel(0, 0)) < qAlpha(framedAlone.pixel(0, 0)));
}
void VignetteCanvasTests::onlyVignettePaintsAnEmptyLayer()
{
Bar bar;
EditorSession &session = bar.session();
session.createDocument(40, 30);
session.addBlankLayer();
QVERIFY(!session.canAdjustColors() && session.canVignette());
QVERIFY(bar.action("vignette").isEnabled() && !bar.action("gaussianBlur").isEnabled());
// Other filters refuse the empty layer.
session.beginFilter(FilterKind::gaussianBlur);
QVERIFY(!session.filterEdit());
// Folders and adjustments hold no pixels to paint.
session.addAdjustment(AdjustmentKind::curves);
session.setAdjustmentEditingID(std::nullopt);
QVERIFY(!session.canVignette() && !bar.action("vignette").isEnabled());
session.addGroup();
QVERIFY(!session.canVignette());
}
void VignetteCanvasTests::anEmptyLayerTakesTheVignetteAcrossTheCanvas()
{
EditorSession session;
session.createDocument(40, 30);
session.addBlankLayer();
const QUuid id = session.activeLayerID().value();
session.beginFilter(FilterKind::vignette);
QVERIFY(prepared(session));
QVERIFY(session.filterEdit().value().startedEmpty && session.filterEdit().value().canvas == QRectF(0, 0, 40, 30));
// Round by default, as Swift 1.2.5 opens it.
QCOMPARE(session.filterEdit().value().settings.vignetteRoundness, 100.0);
QVERIFY(session.filterEdit().value().previewImage(id));
QVERIFY(!session.activeLayer().value().asset);
QVERIFY(committed(session));
QCOMPARE(session.history.undoName(), QString("Vignette"));
const ImageLayer layer = session.activeLayer().value();
const QImage pixels = layer.asset.value().image();
QCOMPARE(pixels.size(), QSize(40, 30));
QCOMPARE(layer.transform.origin, QPointF(0, 0));
// The kernel's canvas-wide fill: black edges, a clear middle.
QImage expected = BrushRaster::context(40, 30, false);
const FilterSettings defaults;
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 0, 0, 40, 30, 1, defaults.vignetteAmount, defaults.vignetteMidpoint,
defaults.vignetteRoundness, defaults.vignetteFeather, defaults.vignetteHighlights, 0, 0, 0);
QCOMPARE(pixels, expected);
QVERIFY(qAlpha(pixels.pixel(20, 15)) == 0 && qAlpha(pixels.pixel(0, 0)) > 80);
session.undo();
QVERIFY(!session.activeLayer().value().asset);
}
void VignetteCanvasTests::theCanvasShowsThePreviewOnAnEmptyLayer()
{
Shown shown(QSize(40, 30));
shown.settle();
shown.session.addBlankLayer();
QCoreApplication::processEvents();
const QImage before = shown.canvas->grab().toImage();
shown.session.beginFilter(FilterKind::vignette);
QVERIFY(prepared(shown.session));
QVERIFY(shown.session.filterEdit().value().previewImage(shown.session.activeLayerID().value()));
QTRY_VERIFY(shown.canvas->grab().toImage() != before);
shown.session.cancelFilter();
QTRY_COMPARE(shown.canvas->grab().toImage(), before);
}
void VignetteCanvasTests::aLayerWithPixelsKeepsItsOwnFrameAndClear()
{
// A grey square in clear pixels, smaller than the canvas.
EditorSession session;
session.createDocument(48, 48);
QImage image = BrushRaster::context(24, 24, false);
QPainter(&image).fillRect(QRect(3, 3, 18, 18), QColor::fromRgbF(0.7, 0.7, 0.7));
session.insert(ImportedImage(image, image, QStringLiteral("Square")));
session.beginFilter(FilterKind::vignette);
QVERIFY(prepared(session));
QVERIFY(!session.filterEdit().value().startedEmpty && !session.filterEdit().value().canvas);
QVERIFY(committed(session));
const QImage pixels = session.activeLayer().value().asset.value().image();
QCOMPARE(pixels.size(), QSize(24, 24));
// Clear stays clear; the square darkens within its own frame.
QCOMPARE(qAlpha(pixels.pixel(0, 0)), 0);
QCOMPARE(qAlpha(pixels.pixel(3, 3)), 255);
QVERIFY(qRed(pixels.pixel(3, 3)) < qRed(pixels.pixel(12, 12)));
}
void VignetteCanvasTests::aSmallEmptyLayerGrowsToTheCanvas()
{
// An empty box inside the canvas, off its corner.
EditorSession session;
session.createDocument(40, 30);
session.addBlankLayer();
const QUuid id = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {3, 7}, .size = {20, 12}}; });
session.selectLayer(id);
session.beginFilter(FilterKind::vignette);
QVERIFY(prepared(session));
QCOMPARE(session.filterEdit().value().previewSource.size(), QSize(40, 30));
QCOMPARE(session.filterEdit().value().grownTransform.value(), (LayerTransform{.origin = {0, 0}, .size = {40, 30}}));
QVERIFY(committed(session));
const ImageLayer layer = session.activeLayer().value();
QCOMPARE(layer.transform, (LayerTransform{.origin = {0, 0}, .size = {40, 30}}));
// The canvas's corner, past the old box, took the colour.
QVERIFY(qAlpha(layer.asset.value().image().pixel(0, 0)) > 80);
}
void VignetteCanvasTests::theFrameIsReadTopDown()
{
// Off-centre and not square, so a flip would show.
const QImage clear = BrushRaster::context(20, 12, false);
FilterSettings settings;
settings.vignetteAmount = 100;
FilterJob job{FilterKind::vignette, clear, settings, 1, std::nullopt, QTransform::fromTranslate(3, 7)};
job.canvas = QRectF(0, 0, 40, 30);
const QImage result = PixelFilter::run(job);
QImage expected = clear.copy();
adjust_colored_vignette(expected.bits(), 20, 12, size_t(expected.bytesPerLine()), -3, -7, 40, 30, 1, 100, 50, 100, 60, 25, 0, 0, 0);
QCOMPARE(result, expected);
QCOMPARE(qAlpha(result.pixel(0, 0)), 100);
}
QTEST_MAIN(VignetteCanvasTests)
#include "VignetteCanvasTests.moc"