Files
OmaPhoto/tests/FloatingSelectionTests.cpp
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-26 00:44:52 -04:00

429 lines
19 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "Document/BrushStroke.h"
#include "Document/Distort.h"
#include "IO/ImageExporter.h"
#include "SelectionFixtures.h"
#include <QRegularExpression>
// Ctrl+T on selected pixels: they float, transform and merge back.
namespace {
// A 100×40 layer: red on the left, blue right.
std::unique_ptr<EditorSession> makeSession()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(100, 40, true);
QImage image = BrushRaster::context(100, 40, false);
QPainter painter(&image);
painter.fillRect(QRect(0, 0, 50, 40), Qt::red);
painter.fillRect(QRect(50, 0, 50, 40), Qt::blue);
painter.end();
session->insert(ImportedImage(image, image, "Halves"));
session->deleteLayer(session->document().value().layers[0].id);
return session;
}
void select(EditorSession &session, const QRectF &rect)
{
session.applySelection(rectPath(rect), SelectionMode::replace, "Select");
}
std::vector<int> pixel(const QImage &image, int x, int y)
{
const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
const uchar *at = bytes.constScanLine(y) + x * 4;
return {at[0], at[1], at[2], at[3]};
}
QImage render(const EditorSession &session)
{
return ImageExporter::render(session.projectSnapshot().value()).image;
}
// Waits for the selected pixels to float.
bool floated(EditorSession &session)
{
bool done = false;
session.beginSelectionTransform([&] { done = true; });
return QTest::qWaitFor([&] { return done; }, 10000) && !session.isProjectBusy();
}
}
class FloatingSelectionTests : public QObject {
Q_OBJECT
private slots:
void transformSelectionMovesPixelsAndOutlineAsOneUndo();
void escapeRestoresExactlyWithoutAnUndoStep();
void applyingAnUnchangedTransformLeavesSoftEdgesUntouched();
void scalingAndMovingPastTheLayerEdgeGrowsTheLayer();
void aDistortedSelectionWarpsItsPixelsAndItsOutline();
void aMergeThatCannotGrowRestoresTheDocument();
void transformSelectionIsRefusedWhereSwiftRefusesIt();
void theFloatingLayerTakesItsSourcesPlaceAndLook();
void aSourceGoneMidLiftPutsTheDocumentBack();
void aMaskGrowsWithTheMergedLayer();
void aTransformedSourceTakesTheMergeInItsOwnGrid();
void aFoldedDistortionDropsTheOutline();
};
void FloatingSelectionTests::transformSelectionMovesPixelsAndOutlineAsOneUndo()
{
const auto session = makeSession();
const QUuid source = session->activeLayerID().value();
select(*session, QRectF(10, 10, 10, 10));
const CanvasDocument before = session->document().value();
const int count = session->history.undoCount();
QVERIFY(session->canTransformSelection());
QVERIFY(floated(*session));
const TransformEdit edit = session->transformEdit().value();
QVERIFY(edit.floating && session->tool() == NavigationTool::move);
QCOMPARE(session->activeLayer().value().name, QString("Floating Selection"));
QCOMPARE(session->transformPixelSize(), std::optional(QSizeF(10, 10)));
LayerTransform draft = edit.draft;
draft.origin = QPointF(70, 20);
session->previewTransform(draft);
QCOMPARE(session->displayedSelection().value().path.boundingRect(), QRectF(70, 20, 10, 10));
session->commitTransform();
QCOMPARE(session->history.undoCount(), count + 1);
QCOMPARE(session->history.undoName(), QString("Transform Selection"));
QVERIFY(session->document().value().layers.size() == 1 && session->activeLayerID() == source);
QCOMPARE(session->selection().value().path.boundingRect(), QRectF(70, 20, 10, 10));
const QImage result = render(*session);
QCOMPARE(pixel(result, 15, 15)[3], 0);
QCOMPARE(pixel(result, 75, 25), (std::vector<int>{255, 0, 0, 255}));
QCOMPARE(pixel(result, 85, 25), (std::vector<int>{0, 0, 255, 255}));
session->undo();
QVERIFY(session->document().value() == before);
}
void FloatingSelectionTests::escapeRestoresExactlyWithoutAnUndoStep()
{
const auto session = makeSession();
select(*session, QRectF(10, 10, 10, 10));
const CanvasDocument before = session->document().value();
const std::optional<QUuid> active = session->activeLayerID();
const int count = session->history.undoCount();
QVERIFY(floated(*session));
LayerTransform draft = session->transformEdit().value().draft;
draft.origin.rx() += 30;
session->previewTransform(draft);
session->cancelTransform();
QVERIFY(session->document().value() == before && session->activeLayerID() == active);
QVERIFY(!session->transformEdit().has_value() && session->history.undoCount() == count);
}
void FloatingSelectionTests::applyingAnUnchangedTransformLeavesSoftEdgesUntouched()
{
const auto session = makeSession();
session->setSelectionAntialiased(true);
QPainterPath ellipse;
ellipse.addEllipse(QRectF(10.3, 5.7, 30, 25));
session->applySelection(ellipse, SelectionMode::replace, "Select");
const CanvasDocument before = session->document().value();
const int count = session->history.undoCount();
QVERIFY(floated(*session));
session->commitTransform();
QVERIFY(session->document().value() == before && session->history.undoCount() == count);
}
void FloatingSelectionTests::scalingAndMovingPastTheLayerEdgeGrowsTheLayer()
{
const auto session = makeSession();
const QUuid source = session->activeLayerID().value();
select(*session, QRectF(0, 0, 10, 10));
QVERIFY(floated(*session));
LayerTransform draft = session->transformEdit().value().draft;
// Twice as large, and hanging off the canvas corner.
draft.size = QSizeF(20, 20);
draft.origin = QPointF(90, 30);
session->previewTransform(draft);
// Switching tools applies it.
session->selectTool(NavigationTool::lasso);
QVERIFY(!session->transformEdit().has_value() && session->history.undoName() == "Transform Selection");
QCOMPARE(layerWith(*session, source).size(), QSizeF(110, 50));
const QImage result = render(*session);
QCOMPARE(pixel(result, 95, 35), (std::vector<int>{255, 0, 0, 255}));
QCOMPARE(pixel(result, 5, 5)[3], 0);
QCOMPARE(session->selection().value().path.boundingRect(), QRectF(90, 30, 20, 20));
}
void FloatingSelectionTests::aDistortedSelectionWarpsItsPixelsAndItsOutline()
{
const auto session = makeSession();
session->setSelectionAntialiased(false);
select(*session, QRectF(10, 10, 20, 20));
QVERIFY(floated(*session));
session->beginDistort();
// One corner pulled out, one pushed in: still convex.
Corners corners = session->transformEdit().value().corners.value();
corners[1] = QPointF(40, 5);
corners[2] = QPointF(22, 22);
session->previewCorners(corners);
session->commitTransform();
QCOMPARE(session->history.undoName(), QString("Transform Selection"));
const QRectF bounds = session->selection().value().path.boundingRect();
QVERIFY2(std::abs(bounds.left() - 10) < 0.01 && std::abs(bounds.top() - 5) < 0.01 && std::abs(bounds.right() - 40) < 0.01
&& std::abs(bounds.bottom() - 30) < 0.01,
qPrintable(QStringLiteral("%1 %2 %3 %4").arg(bounds.left()).arg(bounds.top()).arg(bounds.right()).arg(bounds.bottom())));
// In the old square, outside the new shape: a hole.
const QImage result = render(*session);
QCOMPARE(pixel(result, 28, 28)[3], 0);
QCOMPARE(pixel(result, 14, 14), (std::vector<int>{255, 0, 0, 255}));
// Hard outlines stay hard, warped or moved.
QVERIFY(!session->selection().value().antialiased);
QVERIFY(floated(*session));
LayerTransform draft = session->transformEdit().value().draft;
draft.origin.rx() += 5;
session->previewTransform(draft);
QVERIFY(!session->displayedSelection().value().antialiased);
session->commitTransform();
QVERIFY(!session->selection().value().antialiased);
}
void FloatingSelectionTests::aMergeThatCannotGrowRestoresTheDocument()
{
const auto session = makeSession();
select(*session, QRectF(10, 10, 10, 10));
const CanvasDocument before = session->document().value();
const int count = session->history.undoCount();
QVERIFY(floated(*session));
// Wider than a layer may grow: refused, the document restored.
LayerTransform draft = session->transformEdit().value().draft;
draft.size = QSizeF(35000, 10);
session->previewTransform(draft);
QCOMPARE(session->transformEdit().value().draft.size, QSizeF(35000, 10));
session->commitTransform();
QVERIFY(session->document().value() == before);
QCOMPARE(session->history.undoCount(), count);
QVERIFY(session->brushError().has_value());
// A source without pixels cannot take them.
const QImage dot = BrushRaster::context(4, 4, false);
QVERIFY_THROWS_EXCEPTION(ProjectError, FloatingMerge::merge(dot, LayerTransform{.origin = {0, 0}, .size = {4, 4}}, ImageLayer("Blank", QSizeF(10, 10))));
}
void FloatingSelectionTests::transformSelectionIsRefusedWhereSwiftRefusesIt()
{
const auto session = makeSession();
const QUuid id = session->activeLayerID().value();
// Without a selection Ctrl+T transforms the layer.
QVERIFY(!session->canTransformSelection());
session->transformCommand();
QVERIFY(session->transformEdit().has_value() && !session->transformEdit().value().floating);
session->cancelTransform();
// An empty selection, a mask target, a blank layer: no.
select(*session, QRectF(0, 0, 10, 10));
session->applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, "Subtract");
QVERIFY(!session->canTransformSelection());
select(*session, QRectF(0, 0, 10, 10));
session->addLayerMask(true);
session->selectLayerTarget(id, true);
QVERIFY(!session->canTransformSelection());
session->selectLayerTarget(id, false);
QVERIFY(session->canTransformSelection());
// Hidden, the layer takes no edit.
session->toggleLayerVisibility(id);
QVERIFY(!session->canTransformSelection());
session->toggleLayerVisibility(id);
session->addBlankLayer();
select(*session, QRectF(0, 0, 10, 10));
QVERIFY(!session->canTransformSelection());
// Refused, the callback still comes and nothing changes.
const CanvasDocument before = session->document().value();
QVERIFY(floated(*session));
QVERIFY(session->document().value() == before && !session->transformEdit().has_value());
// Pixels with a selection: Ctrl+T floats them.
session->selectLayer(id);
select(*session, QRectF(0, 0, 10, 10));
session->transformCommand();
QTRY_VERIFY(session->transformEdit().has_value() && session->transformEdit().value().floating);
QVERIFY(!session->canTransformSelection());
session->cancelTransform();
// A sliver under the copy's rounding: nothing to lift.
select(*session, QRectF(10, 10, 0.0005, 10));
QVERIFY(session->canTransformSelection());
const CanvasDocument sliver = session->document().value();
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no selected pixels to lift")));
QVERIFY(floated(*session));
QVERIFY(session->document().value() == sliver && !session->transformEdit().has_value());
}
void FloatingSelectionTests::theFloatingLayerTakesItsSourcesPlaceAndLook()
{
const auto session = makeSession();
const QUuid source = session->activeLayerID().value();
session->groupSelectedLayers();
const std::optional<QUuid> folder = layerWith(*session, source).parentID;
QVERIFY(folder.has_value());
// A layer above, so the floating one's place shows.
session->selectLayer(source);
session->addBlankLayer();
session->selectLayer(source);
session->setLayerOpacity(0.5);
session->setLayerBlendMode(LayerBlendMode::multiply);
session->selectTool(NavigationTool::marquee);
select(*session, QRectF(10, 10, 10, 10));
QVERIFY(floated(*session));
QCOMPARE(session->tool(), NavigationTool::move);
const ImageLayer floating = session->activeLayer().value();
const std::vector<ImageLayer> &layers = session->document().value().layers;
QCOMPARE(indexOf(layers, floating.id), indexOf(layers, source) + 1);
QCOMPARE(floating.parentID, folder);
QCOMPARE(floating.opacity, 0.5);
QCOMPARE(floating.blendMode, LayerBlendMode::multiply);
QCOMPARE(floating.transform.origin, QPointF(10, 10));
session->cancelTransform();
}
void FloatingSelectionTests::aSourceGoneMidLiftPutsTheDocumentBack()
{
const auto session = makeSession();
select(*session, QRectF(10, 10, 10, 10));
const CanvasDocument before = session->document().value();
bool done = false;
session->beginSelectionTransform([&] { done = true; });
QVERIFY(session->isProjectBusy());
// The layer goes mid-clear, as a load would take it.
rewrite(*session, [&](ProjectSnapshot &snapshot) {
snapshot.manifest.layers.clear();
snapshot.images.clear();
snapshot.manifest.activeLayerID = std::nullopt;
});
QTRY_VERIFY(done);
// Swift puts the document from before back.
QVERIFY(session->document().value() == before);
QVERIFY(!session->transformEdit().has_value() && !session->isProjectBusy());
}
void FloatingSelectionTests::aMaskGrowsWithTheMergedLayer()
{
const auto session = makeSession();
const QUuid source = session->activeLayerID().value();
// A black block in the white mask marks its place.
QImage drawn = BrushRaster::context(100, 40, true);
drawn.fill(Qt::white);
for (int y = 20; y < 30; ++y)
std::fill_n(drawn.scanLine(y) + 60, 10, uchar(0));
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, source, LayerMask::assetFrom(drawn)); });
session->selectLayer(source);
const ImageIdentity mask = layerWith(*session, source).mask.value().asset.identity();
// Moved within the layer, the mask stays as it was.
select(*session, QRectF(10, 10, 10, 10));
QVERIFY(floated(*session));
LayerTransform draft = session->transformEdit().value().draft;
draft.origin = QPointF(30, 10);
session->previewTransform(draft);
session->commitTransform();
QCOMPARE(layerWith(*session, source).mask.value().asset.identity(), mask);
// Past the right edge, the mask grows white beside it.
select(*session, QRectF(30, 10, 10, 10));
QVERIFY(floated(*session));
draft = session->transformEdit().value().draft;
draft.origin = QPointF(95, 10);
session->previewTransform(draft);
session->commitTransform();
const ImageLayer grown = layerWith(*session, source);
QCOMPARE(grown.size(), QSizeF(105, 40));
const QImage grownMask = grown.mask.value().asset.image();
QCOMPARE(grownMask.size(), QSize(105, 40));
QCOMPARE(qGray(grownMask.pixel(102, 5)), 255);
QCOMPARE(qGray(grownMask.pixel(65, 25)), 0);
QCOMPARE(qGray(grownMask.pixel(50, 5)), 255);
// Past the left edge, the old mask shifts along.
select(*session, QRectF(0, 20, 10, 10));
QVERIFY(floated(*session));
draft = session->transformEdit().value().draft;
draft.origin = QPointF(-5, 20);
session->previewTransform(draft);
session->commitTransform();
const ImageLayer left = layerWith(*session, source);
QCOMPARE(left.transform.origin, QPointF(-5, 0));
const QImage leftMask = left.mask.value().asset.image();
QCOMPARE(leftMask.size(), QSize(110, 40));
QCOMPARE(qGray(leftMask.pixel(66, 25)), 0);
QCOMPARE(qGray(leftMask.pixel(62, 25)), 255);
QCOMPARE(qGray(leftMask.pixel(2, 25)), 255);
// A mask placed apart keeps its grid.
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).maskPlacement = LayerTransform{.origin = {-5, 0}, .size = {110, 40}}; });
session->selectLayer(source);
const ImageIdentity placed = layerWith(*session, source).mask.value().asset.identity();
select(*session, QRectF(95, 10, 10, 10));
QVERIFY(floated(*session));
draft = session->transformEdit().value().draft;
draft.origin = QPointF(110, 10);
session->previewTransform(draft);
session->commitTransform();
// The canvas clipped the selection: five columns float.
QCOMPARE(layerWith(*session, source).size(), QSizeF(120, 40));
QCOMPARE(layerWith(*session, source).mask.value().asset.identity(), placed);
}
void FloatingSelectionTests::aTransformedSourceTakesTheMergeInItsOwnGrid()
{
const std::vector<int> red{255, 0, 0, 255}, blue{0, 0, 255, 255};
QImage image = BrushRaster::context(50, 20, false);
image.fill(Qt::blue);
QPainter painter(&image);
painter.fillRect(QRect(0, 0, 25, 20), Qt::red);
painter.end();
// Doubled at 20,10: red to 70, blue past it.
EditorSession scaled;
scaled.createDocument(200, 100, true);
scaled.insert(ImportedImage(image, image, "Halves"));
const QUuid id = scaled.activeLayerID().value();
const LayerTransform doubled{.origin = {20, 10}, .size = {100, 40}};
rewrite(scaled, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = doubled; });
scaled.selectLayer(id);
select(scaled, QRectF(30, 20, 20, 20));
QVERIFY(floated(scaled));
LayerTransform draft = scaled.transformEdit().value().draft;
draft.origin = QPointF(80, 20);
scaled.previewTransform(draft);
scaled.commitTransform();
QCOMPARE(layerWith(scaled, id).transform, doubled);
QCOMPARE(layerWith(scaled, id).asset.value().size(), QSize(50, 20));
QImage result = render(scaled);
QCOMPARE(pixel(result, 90, 30), red);
QCOMPARE(pixel(result, 110, 30), blue);
QCOMPARE(pixel(result, 40, 30)[3], 0);
// Turned a quarter: the block lands where it was put.
EditorSession turned;
turned.createDocument(200, 100, true);
turned.insert(ImportedImage(image, image, "Halves"));
const QUuid other = turned.activeLayerID().value();
const LayerTransform quarter{.origin = {20, 10}, .size = {100, 40}, .rotation = 90};
rewrite(turned, [&](ProjectSnapshot &snapshot) { record(snapshot, other).transform = quarter; });
turned.selectLayer(other);
const QImage before = render(turned);
const std::vector<int> above = pixel(before, 70, 20);
QVERIFY(above[3] == 255 && above != pixel(before, 70, 50));
select(turned, QRectF(66, 16, 8, 8));
QVERIFY(floated(turned));
draft = turned.transformEdit().value().draft;
draft.origin.ry() += 30;
turned.previewTransform(draft);
turned.commitTransform();
QCOMPARE(layerWith(turned, other).transform, quarter);
result = render(turned);
QCOMPARE(pixel(result, 70, 50), above);
QCOMPARE(pixel(result, 70, 20)[3], 0);
}
void FloatingSelectionTests::aFoldedDistortionDropsTheOutline()
{
const auto session = makeSession();
select(*session, QRectF(10, 10, 20, 20));
const DocumentSelection outline = session->selection().value();
QVERIFY(floated(*session));
session->beginDistort();
// A bow tie is usable but folded: no outline carries.
const Corners folded = {QPointF(10, 10), QPointF(30, 30), QPointF(30, 10), QPointF(10, 30)};
session->previewCorners(folded);
QVERIFY(session->transformEdit().value().corners.value() == folded);
session->commitTransform();
QCOMPARE(session->history.undoName(), QString("Transform Selection"));
QVERIFY(!session->selection().has_value());
session->undo();
QVERIFY(session->selection().value() == outline);
}
QTEST_GUILESS_MAIN(FloatingSelectionTests)
#include "FloatingSelectionTests.moc"