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

+279
View File
@@ -0,0 +1,279 @@
#include "BrushFixtures.h"
#include "CanvasFixtures.h"
#include "SelectionCanvasFixtures.h"
#include "SelectionFixtures.h"
// Painting on the canvas: nothing away from the brush moves.
namespace {
struct Shift {
int start;
int finish;
bool committed;
};
// Swift's canvasShift: a big photo scaled down, painted mid.
Shift canvasShift(bool paintingMask, double layerScale = 0.25)
{
Shown shown(QSize(800, 600), QSize(800, 600));
EditorSession &session = shown.session;
const QImage photo = noise(2400, 1800, 3);
session.insert(ImportedImage(photo, photo, "Photo"));
const QUuid id = session.activeLayerID().value();
LayerTransform transform{.origin = {100, 75}, .size = {2400 * layerScale, 1800 * layerScale}};
transform.sampling = LayerSampling::high;
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = transform; });
session.selectLayer(id);
if (paintingMask) {
session.addLayerMask();
if (!session.isMaskSelected())
throw std::runtime_error("the mask was not targeted");
}
shown.settle();
session.selectTool(NavigationTool::brush);
BrushSettings settings = session.brushSettings();
settings.diameter = 30;
session.setBrushSettings(settings);
const auto snapshot = [&] {
shown.canvas->synchronizeDisplay();
return shown.canvas->grab().toImage().convertToFormat(QImage::Format_RGBA8888);
};
// The brushed area in view pixels, grown generously.
const QPointF brush = transform.center();
const QSizeF size = session.document().value().size();
const QPointF topLeft = session.viewport.viewPoint(QPointF(brush.x() - 60, brush.y() - 40), size);
const QPointF bottomRight = session.viewport.viewPoint(QPointF(brush.x() + 80, brush.y() + 40), size);
const auto largestDifference = [&](const QImage &a, const QImage &b) {
const double perPoint = double(a.width()) / shown.canvas->width();
const QRectF box(topLeft.x() * perPoint, topLeft.y() * perPoint, (bottomRight.x() - topLeft.x()) * perPoint, (bottomRight.y() - topLeft.y()) * perPoint);
int largest = 0;
for (int y = 0; y < a.height(); ++y) {
const uchar *rowA = a.constScanLine(y), *rowB = b.constScanLine(y);
for (int x = 0; x < a.width(); ++x) {
if (box.contains(QPointF(x, y)))
continue;
for (int c = 0; c < 4; ++c)
largest = std::max(largest, std::abs(int(rowA[x * 4 + c]) - int(rowB[x * 4 + c])));
}
}
return largest;
};
const QImage before = snapshot();
session.beginBrush(brush);
session.continueBrush(QPointF(brush.x() + 20, brush.y()));
const QImage during = snapshot();
session.finishBrushImmediately();
const QImage after = snapshot();
const ImageLayer layer = session.activeLayer().value();
const bool committed = paintingMask ? layer.mask.value().asset.raster != nullptr : layer.asset.value().raster != nullptr;
return {largestDifference(before, during), largestDifference(during, after), committed};
}
}
class BrushCanvasTests : public QObject {
Q_OBJECT
private slots:
void paintingAScaledDownLayerDoesNotShiftItsPixels();
void paintingAScaledDownLayersMaskDoesNotShiftItsPixels();
void theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes();
void aPlacedMaskStrokeShowsTheLayerThroughItsPreview();
void aStrokeStepRepaintsWhereItWentAlone();
void paintingAFolderMaskClipsItsLayersLive();
void aStripedFolderMaskKeepsItsShadeWhilePainted();
};
void BrushCanvasTests::paintingAScaledDownLayerDoesNotShiftItsPixels()
{
const Shift shift = canvasShift(false);
QVERIFY2(shift.committed, "the stroke was committed");
QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint changed pixels away from the brush by %1").arg(shift.start)));
QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the stroke changed pixels away from the brush by %1").arg(shift.finish)));
}
void BrushCanvasTests::paintingAScaledDownLayersMaskDoesNotShiftItsPixels()
{
const Shift shift = canvasShift(true);
QVERIFY2(shift.committed, "the mask stroke was committed");
QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint the mask changed pixels away from the brush by %1").arg(shift.start)));
QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the mask stroke changed pixels away from the brush by %1").arg(shift.finish)));
}
void BrushCanvasTests::theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes()
{
Shown shown(QSize(400, 300), QSize(400, 300));
EditorSession &session = shown.session;
QImage blue = BrushRaster::context(400, 300, false);
blue.fill(Qt::blue);
session.insert(ImportedImage(blue, blue, "Blue"));
shown.settle();
session.zoom(1);
session.selectTool(NavigationTool::brush);
session.setBrushSettings(brush(20, 1, 1, 0, 0));
shown.canvas->synchronizeDisplay();
// Each step of a stroke is a new display state.
session.beginBrush(QPointF(50, 50));
QVERIFY(shown.canvas->synchronizeDisplay());
QCOMPARE(shown.canvas->grab().toImage().pixelColor(50, 50), QColor(Qt::red));
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::blue));
session.continueBrush(QPointF(200, 50));
QVERIFY(shown.canvas->synchronizeDisplay());
QVERIFY(!shown.canvas->synchronizeDisplay());
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red));
session.finishBrush();
QVERIFY(shown.canvas->synchronizeDisplay());
QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red));
// Delete clears a selection; without one it deletes the layer.
session.selectTool(NavigationTool::marquee);
session.applySelection(rectPath(QRectF(0, 0, 100, 300)), SelectionMode::replace, "Select");
QTRY_VERIFY(shown.canvas->hasFocus());
QTest::keyClick(shown.canvas, Qt::Key_Delete);
QTRY_COMPARE(session.history.undoName(), QString("Clear"));
session.deselect();
const int layers = int(session.document().value().layers.size());
QTest::keyClick(shown.canvas, Qt::Key_Backspace);
QCOMPARE(int(session.document().value().layers.size()), layers - 1);
// With Ctrl or Alt the key is not Delete's.
session.undo();
QTest::keyClick(shown.canvas, Qt::Key_Delete, Qt::ControlModifier);
QCOMPARE(int(session.document().value().layers.size()), layers);
}
void BrushCanvasTests::aPlacedMaskStrokeShowsTheLayerThroughItsPreview()
{
Shown shown(QSize(200, 200), QSize(200, 200));
EditorSession &session = shown.session;
QImage blue = BrushRaster::context(64, 64, false);
blue.fill(Qt::blue);
session.insert(ImportedImage(blue, blue, "Blue"), QPointF(96, 56));
const QUuid id = session.activeLayerID().value();
// A black 16 px mask at 32 from (80,40): hidden.
rewrite(session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, LayerMask::assetFrom(BrushRaster::context(16, 16, true)));
record(snapshot, id).maskPlacement = LayerTransform{.origin = {80, 40}, .size = {32, 32}};
record(snapshot, id).maskLinked = false;
});
session.selectLayerTarget(id, true);
shown.settle();
session.zoom(1);
session.selectTool(NavigationTool::brush);
session.setBrushSettings(brush(8, 1, 1, 1, 1));
session.setMaskPaintWhite(true);
shown.canvas->synchronizeDisplay();
QVERIFY(shown.canvas->grab().toImage().pixelColor(88, 48) != QColor(Qt::blue));
session.beginBrush(QPointF(88, 48));
QVERIFY(shown.canvas->synchronizeDisplay());
QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue));
QVERIFY(shown.canvas->grab().toImage().pixelColor(70, 70) != QColor(Qt::blue));
session.finishBrush();
QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue));
}
void BrushCanvasTests::aStrokeStepRepaintsWhereItWentAlone()
{
Shown shown(QSize(400, 300), QSize(400, 300));
EditorSession &session = shown.session;
session.addBlankLayer();
shown.settle();
session.zoom(1);
session.selectTool(NavigationTool::brush);
session.setBrushSettings(brush(20, 1, 1, 0, 0));
shown.canvas->synchronizeDisplay();
QTest::qWait(50);
PaintSpy spy(*shown.canvas);
// The first tile alone, two points out: not everything.
session.beginBrush(QPointF(50, 50));
QVERIFY(shown.canvas->synchronizeDisplay());
QTRY_VERIFY(spy.count > 0);
QCOMPARE(spy.painted, QRect(0, 0, 258, 258));
spy.painted = QRect();
spy.count = 0;
// Into the next tile: both, still not everything.
session.continueBrush(QPointF(300, 50));
QVERIFY(shown.canvas->synchronizeDisplay());
QTRY_VERIFY(spy.count > 0);
QCOMPARE(spy.painted, QRect(0, 0, 400, 258));
session.cancelBrush();
}
void BrushCanvasTests::paintingAFolderMaskClipsItsLayersLive()
{
Shown shown(QSize(300, 300), QSize(300, 300));
EditorSession &session = shown.session;
QImage red = BrushRaster::context(100, 100, false);
red.fill(Qt::red);
session.insert(ImportedImage(red, red, "Red"), QPointF(100, 100));
session.groupSelectedLayers();
session.addLayerMask(true);
QVERIFY(session.isMaskSelected() && session.activeLayer().value().isGroup);
shown.settle();
session.selectTool(NavigationTool::brush);
session.setBrushSettings(brush(20, 1, 0, 0, 0));
session.setMaskPaintWhite(false);
for (double zoom : {1.0, 3.0}) {
session.zoom(zoom);
shown.canvas->synchronizeDisplay();
// Both points on screen at 3x: the view spans 100–200.
const QSizeF size = session.document().value().size();
const QPoint centre = session.viewport.viewPoint(QPointF(125, 125), size).toPoint();
const QPoint edge = session.viewport.viewPoint(QPointF(105, 105), size).toPoint();
QVERIFY(QRect(QPoint(0, 0), shown.canvas->size()).contains(edge));
QCOMPARE(shown.canvas->grab().toImage().pixelColor(centre), QColor(Qt::red));
// Black on the mask hides the red at once.
session.beginBrush(QPointF(125, 125));
QVERIFY(shown.canvas->synchronizeDisplay());
QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red));
QCOMPARE(shown.canvas->grab().toImage().pixelColor(edge), QColor(Qt::red));
session.finishBrush();
shown.canvas->synchronizeDisplay();
QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red));
session.undo();
QVERIFY(session.isMaskSelected());
}
}
void BrushCanvasTests::aStripedFolderMaskKeepsItsShadeWhilePainted()
{
Shown shown(QSize(1200, 1200), QSize(300, 300));
EditorSession &session = shown.session;
QImage red = BrushRaster::context(1200, 1200, false);
red.fill(Qt::red);
session.insert(ImportedImage(red, red, "Red"));
session.groupSelectedLayers();
const QUuid folder = session.activeLayerID().value();
// One-pixel stripes: halved at a quarter, they read gray.
QImage stripes = BrushRaster::context(1200, 1200, true);
for (int y = 0; y < 1200; ++y) {
for (int x = 0; x < 1200; x += 2)
stripes.scanLine(y)[x] = 255;
}
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(stripes)); });
session.selectLayerTarget(folder, true);
QVERIFY(session.isMaskSelected());
shown.settle();
session.zoom(0.25);
session.selectTool(NavigationTool::brush);
session.setBrushSettings(brush(40, 1, 1, 1, 1));
session.setMaskPaintWhite(true);
shown.canvas->synchronizeDisplay();
const QPoint far = session.viewport.viewPoint(QPointF(1000, 1000), session.document().value().size()).toPoint();
const auto shade = [&] { return shown.canvas->grab().toImage().pixelColor(far); };
const auto near = [](const QColor &a, const QColor &b) {
return std::abs(a.red() - b.red()) <= 3 && std::abs(a.green() - b.green()) <= 3 && std::abs(a.blue() - b.blue()) <= 3;
};
const QColor before = shade();
QVERIFY(before != QColor(Qt::red));
// Far from each dab, the untouched shade stays put.
for (const QPointF dab : {QPointF(100, 100), QPointF(100, 300)}) {
session.beginBrush(dab);
QVERIFY(shown.canvas->synchronizeDisplay());
const QColor during = shade();
QVERIFY2(near(before, during), qPrintable(QStringLiteral("%1 then %2").arg(before.name(), during.name())));
session.finishBrush();
shown.canvas->synchronizeDisplay();
QVERIFY(near(before, shade()));
// The second dab reads the tiles the first one painted.
QVERIFY(layerWith(session, folder).mask.value().asset.raster);
}
}
QTEST_MAIN(BrushCanvasTests)
#include "BrushCanvasTests.moc"