OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

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>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
#include "BrushFixtures.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include <QSignalSpy>
#include <cmath>
#include <numbers>
// Swift's ShapeToolTests, and the draft's own rules.
namespace {
std::unique_ptr<EditorSession> makeSession()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(100, 80, true);
session->selectTool(NavigationTool::shape);
session->setForegroundColor(PaletteColor{1, 0, 0});
return session;
}
void drag(EditorSession &session, QPointF start, QPointF end, bool square = false, bool fromCenter = false)
{
session.beginShape(start);
session.dragShape(end, square, fromCenter);
session.finishShape();
}
// One exported pixel's red and alpha, as Swift's reader.
std::pair<int, int> redAlpha(const EditorSession &session, int x, int y)
{
const std::vector<int> at = pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y);
return {at[0], at[3]};
}
QStringList names(const EditorSession &session)
{
QStringList list;
for (const ImageLayer &layer : session.document().value().layers)
list << layer.name;
return list;
}
const std::pair<int, int> solid{255, 255};
}
class ShapeToolTests : public QObject {
Q_OBJECT
private slots:
void rectangleFillsANewLayerWithTheForegroundColorAsOneUndoStep();
void ellipseLeavesItsCornersClearWithShiftCircleAndOptionFromCenter();
void aClickEscapeOrToolSwitchMakesNoLayer();
void roundedRectanglesFollowTheRadiusAndClampToAPill();
void aLineKeepsItsEndsThicknessAndRoundCaps();
void shiftSnapsALineToEighthsOfATurn();
void aShapeTooLargeIsRefused();
void namesSkipThoseInUse();
void theDraftWaitsForEditableLayers();
void tabAndTheKindsCancelTheDraft();
void everyChangeIsAnnounced();
void pathsFollowSwift();
void flatDragsMakeNothing();
void edgesAreSmoothAndLinesAPixelWide();
};
void ShapeToolTests::rectangleFillsANewLayerWithTheForegroundColorAsOneUndoStep()
{
const auto session = makeSession();
session->selectAll();
const int count = session->history.undoCount();
drag(*session, QPointF(10, 10), QPointF(40, 30));
QCOMPARE(names(*session), (QStringList{"Layer 1", "Rectangle 1"}));
QCOMPARE(session->activeLayer().value().name, QString("Rectangle 1"));
QCOMPARE(session->history.undoCount(), count + 1);
QCOMPARE(session->history.undoName(), QString("Rectangle"));
QCOMPARE(session->activeLayer().value().transform.origin, QPointF(10, 10));
QCOMPARE(session->activeLayer().value().transform.size, QSizeF(30, 20));
// Unlike Paste, drawing a shape keeps the selection.
QVERIFY(session->selection());
QCOMPARE(redAlpha(*session, 25, 20), solid);
QCOMPARE(redAlpha(*session, 10, 10), solid);
QCOMPARE(redAlpha(*session, 39, 29), solid);
QCOMPARE(redAlpha(*session, 9, 20).second, 0);
QCOMPARE(redAlpha(*session, 40, 20).second, 0);
QCOMPARE(redAlpha(*session, 25, 30).second, 0);
drag(*session, QPointF(60, 10), QPointF(70, 20));
QCOMPARE(session->activeLayer().value().name, QString("Rectangle 2"));
session->undo();
session->undo();
QCOMPARE(names(*session), (QStringList{"Layer 1"}));
}
void ShapeToolTests::ellipseLeavesItsCornersClearWithShiftCircleAndOptionFromCenter()
{
const auto session = makeSession();
session->toggleShapeKind();
QCOMPARE(session->shapeKind(), ShapeKind::ellipse);
drag(*session, QPointF(50, 40), QPointF(60, 45), true, true);
QCOMPARE(session->activeLayer().value().name, QString("Ellipse 1"));
QCOMPARE(session->activeLayer().value().transform.origin, QPointF(40, 30));
QCOMPARE(session->activeLayer().value().transform.size, QSizeF(20, 20));
QCOMPARE(redAlpha(*session, 50, 40), solid);
QVERIFY(redAlpha(*session, 41, 40).second > 0 && redAlpha(*session, 50, 31).second > 0);
// Outside the circle, inside its box.
QCOMPARE(redAlpha(*session, 40, 30).second, 0);
QCOMPARE(redAlpha(*session, 59, 49).second, 0);
}
void ShapeToolTests::aClickEscapeOrToolSwitchMakesNoLayer()
{
const auto session = makeSession();
const int count = session->history.undoCount();
session->beginShape(QPointF(20, 20));
session->finishShape();
session->beginShape(QPointF(20, 20));
session->dragShape(QPointF(50, 50), false, false);
QCOMPARE(session->shapeDraft().value().rect, QRectF(20, 20, 30, 30));
session->cancelShape();
session->beginShape(QPointF(20, 20));
session->dragShape(QPointF(50, 50), false, false);
session->selectTool(NavigationTool::brush);
QVERIFY(!session->shapeDraft());
QCOMPARE(session->history.undoCount(), count);
QCOMPARE(session->document().value().layers.size(), size_t(1));
}
void ShapeToolTests::roundedRectanglesFollowTheRadiusAndClampToAPill()
{
const auto session = makeSession();
session->setShapeCornerRadius(8);
drag(*session, QPointF(10, 10), QPointF(50, 40));
session->setShapeCornerRadius(500);
// 40 × 20: a radius of 10.
drag(*session, QPointF(55, 50), QPointF(95, 70));
QCOMPARE(redAlpha(*session, 10, 10).second, 0);
QCOMPARE(redAlpha(*session, 11, 11).second, 0);
QCOMPARE(redAlpha(*session, 13, 13).second, 255);
QCOMPARE(redAlpha(*session, 30, 10).second, 255);
QCOMPARE(redAlpha(*session, 30, 25), solid);
QCOMPARE(redAlpha(*session, 55, 50).second, 0);
QCOMPARE(redAlpha(*session, 75, 60), solid);
QCOMPARE(session->document().value().layers.size(), size_t(3));
QCOMPARE(session->activeLayer().value().shape.value().style.cornerRadius, 500.0);
session->toggleShapeKind();
session->beginShape(QPointF(5, 5));
QCOMPARE(session->shapeDraft().value().cornerRadius, 0.0);
session->cancelShape();
}
void ShapeToolTests::aLineKeepsItsEndsThicknessAndRoundCaps()
{
const auto session = makeSession();
session->setForegroundColor(PaletteColor{1, 0.5, 0.25});
session->setShapeKind(ShapeKind::line);
session->beginShape(QPointF(10, 10));
// Undragged, a line has no end yet.
QVERIFY(!session->shapeLineEnds());
session->dragShape(QPointF(50, 30), false, false);
QCOMPARE(session->shapeLineEnds().value(), std::pair(QPointF(10, 10), QPointF(50, 30)));
session->finishShape();
// The ends' box, grown by half the default 4.
const ImageLayer line = session->activeLayer().value();
QCOMPARE(line.name, QString("Line 1"));
QCOMPARE(line.transform.origin, QPointF(8, 8));
QCOMPARE(line.transform.size, QSizeF(44, 24));
const LayerShapeStyle style = line.liveShape().value().style;
QCOMPARE(style.kind, ShapeKind::line);
QCOMPARE(style.color(), (PaletteColor{1, 0.5, 0.25}));
QCOMPARE(style.lineWidth.value(), 4.0);
QCOMPARE(style.start.value(), QPointF(2.0 / 44, 2.0 / 24));
QCOMPARE(style.end.value(), QPointF(42.0 / 44, 22.0 / 24));
QCOMPARE(redAlpha(*session, 30, 20), solid);
QCOMPARE(redAlpha(*session, 20, 25).second, 0);
// Past its start, inside the round cap alone.
QVERIFY(redAlpha(*session, 8, 9).second > 100);
QCOMPARE(redAlpha(*session, 50, 30), solid);
// Dragged back the other way: the same box, ends swapped.
session->setShapeLineWidth(6);
session->beginShape(QPointF(50, 30));
session->dragShape(QPointF(10, 10), false, false);
QCOMPARE(session->shapeLineEnds().value().first, QPointF(50, 30));
session->finishShape();
const ImageLayer back = session->activeLayer().value();
QCOMPARE(back.transform.origin, QPointF(7, 7));
QCOMPARE(back.transform.size, QSizeF(46, 26));
QCOMPARE(back.shape.value().style.start.value(), QPointF(43.0 / 46, 23.0 / 26));
QCOMPARE(back.shape.value().style.lineWidth.value(), 6.0);
// A click leaves a dot: the box grows by thickness.
session->beginShape(QPointF(70, 60));
session->finishShape();
const ImageLayer dot = session->activeLayer().value();
QCOMPARE(dot.name, QString("Line 3"));
QCOMPARE(dot.transform.origin, QPointF(67, 57));
QCOMPARE(dot.transform.size, QSizeF(6, 6));
QCOMPARE(dot.shape.value().style.start.value(), QPointF(0.5, 0.5));
QCOMPARE(redAlpha(*session, 70, 60), solid);
// A disc: its box's corners barely touched, its edges full.
QVERIFY(redAlpha(*session, 67, 57).second < 20 && redAlpha(*session, 67, 60).second > 150);
// A box that ends between pixels keeps its whole pixels.
session->beginShape(QPointF(10, 40));
session->dragShape(QPointF(50.5, 60), false, false);
session->finishShape();
QCOMPARE(session->activeLayer().value().asset.value().size(), QSize(46, 26));
QCOMPARE(session->activeLayer().value().transform.origin, QPointF(7, 37));
}
void ShapeToolTests::shiftSnapsALineToEighthsOfATurn()
{
const auto session = makeSession();
session->setShapeKind(ShapeKind::line);
session->beginShape(QPointF(10.6, 9.4));
QCOMPARE(session->shapeDraft().value().anchor, QPointF(11, 9));
session->beginShape(QPointF(10.4, 9.6));
QCOMPARE(session->shapeDraft().value().anchor, QPointF(10, 10));
session->dragShape(QPointF(50, 33), true, false);
const double length = std::hypot(40.0, 23.0), leg = length * std::cos(std::numbers::pi / 4);
const QPointF end = session->shapeLineEnds().value().second;
QVERIFY(std::abs(end.x() - (10 + leg)) < 1e-9 && std::abs(end.y() - (10 + leg)) < 1e-9);
QCOMPARE(session->shapeDraft().value().rect, DragBox::rect(QPointF(10, 10), end, false, false));
// Flat stays flat; Alt grows the box both ways.
session->dragShape(QPointF(50, 14), true, true);
QVERIFY(std::abs(session->shapeLineEnds().value().second.y() - 10) < 1e-9);
QCOMPARE(session->shapeDraft().value().rect, DragBox::rect(QPointF(10, 10), session->shapeLineEnds().value().second, false, true));
// Without Shift a line ends where the pointer is.
session->dragShape(QPointF(33.3, 21.7), false, false);
QCOMPARE(session->shapeLineEnds().value().second, QPointF(33.3, 21.7));
QCOMPARE(session->shapeDraft().value().rect, QRectF(10, 10, 23, 12));
}
void ShapeToolTests::aShapeTooLargeIsRefused()
{
EditorSession session;
session.createDocument(20'000, 12'000, true);
session.selectTool(NavigationTool::shape);
// Each side truncated first: 10,001 × 10,000 is over.
session.beginShape(QPointF(0, 0));
session.dragShape(QPointF(10'001, 10'000), false, false);
session.finishShape();
QCOMPARE(session.brushError().value(), QString("That shape is too large. A shape can cover up to 100 megapixels."));
QCOMPARE(session.document().value().layers.size(), size_t(1));
QVERIFY(!session.shapeDraft());
}
void ShapeToolTests::namesSkipThoseInUse()
{
EditorSession empty;
QCOMPARE(empty.nextShapeName(ShapeKind::ellipse), QString("Ellipse 1"));
const auto session = makeSession();
session->renameLayer(session->activeLayerID().value(), QStringLiteral("Rectangle 1"));
QCOMPARE(session->nextShapeName(ShapeKind::rectangle), QString("Rectangle 2"));
QCOMPARE(session->nextShapeName(ShapeKind::line), QString("Line 1"));
drag(*session, QPointF(1, 1), QPointF(5, 5));
QCOMPARE(session->activeLayer().value().name, QString("Rectangle 2"));
}
void ShapeToolTests::theDraftWaitsForEditableLayers()
{
const auto session = makeSession();
// Only the Shape tool, a finite point, editable layers begin.
session->selectTool(NavigationTool::brush);
session->beginShape(QPointF(5, 5));
QVERIFY(!session->shapeDraft());
session->selectTool(NavigationTool::shape);
session->beginShape(QPointF(NAN, 5));
session->beginShape(QPointF(5, INFINITY));
QVERIFY(!session->shapeDraft());
session->setIsProjectBusy(true);
session->beginShape(QPointF(5, 5));
QVERIFY(!session->shapeDraft());
session->setIsProjectBusy(false);
session->beginShape(QPointF(5, 5));
session->dragShape(QPointF(NAN, 20), false, false);
session->dragShape(QPointF(20, -INFINITY), false, false);
QCOMPARE(session->shapeDraft().value().rect, QRectF(5, 5, 0, 0));
// A draft finished while busy makes nothing.
session->dragShape(QPointF(20, 20), false, false);
session->setIsProjectBusy(true);
session->finishShape();
QVERIFY(!session->shapeDraft());
QCOMPARE(session->document().value().layers.size(), size_t(1));
session->setIsProjectBusy(false);
// Without a draft, a drag or finish changes nothing.
session->dragShape(QPointF(30, 30), false, false);
session->finishShape();
QVERIFY(!session->shapeDraft());
QCOMPARE(session->document().value().layers.size(), size_t(1));
}
void ShapeToolTests::tabAndTheKindsCancelTheDraft()
{
const auto session = makeSession();
session->beginShape(QPointF(5, 5));
session->toggleShapeKind();
QVERIFY(!session->shapeDraft());
QCOMPARE(session->shapeKind(), ShapeKind::ellipse);
session->toggleShapeKind();
QCOMPARE(session->shapeKind(), ShapeKind::line);
session->toggleShapeKind();
QCOMPARE(session->shapeKind(), ShapeKind::rectangle);
session->beginShape(QPointF(5, 5));
session->cycleToolMode();
QVERIFY(!session->shapeDraft());
QCOMPARE(session->shapeKind(), ShapeKind::ellipse);
// Choosing the tool it has keeps the draft.
session->beginShape(QPointF(5, 5));
session->selectTool(NavigationTool::shape);
QVERIFY(session->shapeDraft());
QCOMPARE(session->shapeDraft().value().kind, ShapeKind::ellipse);
}
void ShapeToolTests::everyChangeIsAnnounced()
{
const auto session = makeSession();
QSignalSpy spy(session.get(), &EditorSession::changed);
session->setShapeKind(ShapeKind::line);
session->setShapeLineWidth(9);
session->setShapeCornerRadius(3);
QCOMPARE(spy.count(), 3);
QCOMPARE(session->shapeLineWidth(), 9.0);
QCOMPARE(session->shapeCornerRadius(), 3.0);
session->beginShape(QPointF(1, 1));
session->dragShape(QPointF(9, 9), false, false);
session->cancelShape();
QCOMPARE(spy.count(), 6);
session->cancelShape();
QCOMPARE(spy.count(), 6);
// A rectangle's click clears the draft, one signal.
session->setShapeKind(ShapeKind::rectangle);
session->beginShape(QPointF(1, 1));
spy.clear();
session->finishShape();
QCOMPARE(spy.count(), 1);
QVERIFY(!session->shapeDraft());
}
void ShapeToolTests::pathsFollowSwift()
{
const QRectF tall(0, 0, 20, 40);
QPainterPath expected;
expected.addRoundedRect(tall, 10, 10);
// The radius stops at half the shorter side, either way.
QCOMPARE(path(ShapeKind::rectangle, tall, 500), expected);
QCOMPARE(path(ShapeKind::rectangle, tall.transposed(), 500), [] {
QPainterPath wide;
wide.addRoundedRect(QRectF(0, 0, 40, 20), 10, 10);
return wide;
}());
QPainterPath square;
square.addRect(tall);
QCOMPARE(path(ShapeKind::rectangle, tall, -5), square);
QCOMPARE(path(ShapeKind::rectangle, tall), square);
QPainterPath ellipse;
ellipse.addEllipse(tall);
QCOMPARE(path(ShapeKind::ellipse, tall, 8), ellipse);
QCOMPARE(rawValue(ShapeKind::rectangle) + rawValue(ShapeKind::ellipse) + rawValue(ShapeKind::line), QString("RectangleEllipseLine"));
QCOMPARE(shapeKind(QStringLiteral("Line")), std::optional(ShapeKind::line));
QCOMPARE(shapeKind(QStringLiteral("Ellipse")), std::optional(ShapeKind::ellipse));
QVERIFY(!shapeKind(QStringLiteral("line")));
}
void ShapeToolTests::flatDragsMakeNothing()
{
const auto session = makeSession();
drag(*session, QPointF(20, 20), QPointF(50, 20));
drag(*session, QPointF(20, 20), QPointF(20, 50));
QCOMPARE(session->document().value().layers.size(), size_t(1));
QVERIFY(!session->brushError());
}
void ShapeToolTests::edgesAreSmoothAndLinesAPixelWide()
{
const auto session = makeSession();
session->setShapeKind(ShapeKind::ellipse);
drag(*session, QPointF(40, 30), QPointF(60, 50));
// The circle's rim crosses this pixel: part covered.
const int rim = redAlpha(*session, 40, 38).second;
QVERIFY2(rim > 40 && rim < 250, qPrintable(QString::number(rim)));
// Thinner than a pixel, a line still draws one wide.
session->setShapeKind(ShapeKind::line);
session->setShapeLineWidth(0.4);
drag(*session, QPointF(10, 10), QPointF(50, 30));
const QImage line = session->activeLayer().value().asset.value().image();
int strongest = 0;
for (int y = 0; y < line.height(); ++y)
strongest = std::max(strongest, alpha(line, 20, y));
QVERIFY2(strongest > 150, qPrintable(QString::number(strongest)));
// Pixel-wide is still an outline: 1 and 1.001 agree.
session->setShapeLineWidth(1);
drag(*session, QPointF(10, 40), QPointF(50, 60));
const QImage one = session->activeLayer().value().asset.value().image();
session->setShapeLineWidth(1.001);
drag(*session, QPointF(10, 40), QPointF(50, 60));
const QImage wider = session->activeLayer().value().asset.value().image();
QCOMPARE(one.size(), wider.size());
int apart = 0;
for (int y = 0; y < one.height(); ++y) {
for (int x = 0; x < one.width(); ++x)
apart = std::max(apart, std::abs(alpha(one, x, y) - alpha(wider, x, y)));
}
QVERIFY2(apart <= 3, qPrintable(QString::number(apart)));
}
QTEST_GUILESS_MAIN(ShapeToolTests)
#include "ShapeToolTests.moc"