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

+343
View File
@@ -0,0 +1,343 @@
#include "SelectionCanvasFixtures.h"
// What the selection tools show: cursors, ants, autoscroll, drawing.
class SelectionOverlayTests : public QObject {
Q_OBJECT
private slots:
void cursorsFollowTheToolTheKindTheModeAndTheOutline();
void theAntsMarchWhileASelectionShowsAndStopWithout();
void autoscrollPansTheViewWhileADragHoldsAtTheEdge();
void theOutlineAndTheDraftAreDrawnWhereTheyLie();
void outlinesAndDraftsAreAntialiased();
void theAntsDashesKeepFourOnFourOffAtTwoTimes();
void openDraftsAndCursorBadgesEndWhereTheyEnd();
};
void SelectionOverlayTests::cursorsFollowTheToolTheKindTheModeAndTheOutline()
{
Canvas shown;
EditorSession &session = shown.session;
CanvasView &canvas = *shown.canvas;
const double ratio = canvas.devicePixelRatio();
shown.hover(QPointF(200, 150));
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, ratio)));
shown.hover(QPointF(200, 150), Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::add, ratio)));
QVERIFY(session.displayedSelectionMode() == SelectionMode::add);
shown.hover(QPointF(200, 150), Qt::AltModifier | Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::subtract, ratio)));
// A key from anywhere in the window changes the badge.
QTest::keyPress(shown.window.windowHandle(), Qt::Key_Shift, Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::add, ratio)));
QVERIFY(session.displayedSelectionMode() == SelectionMode::add);
QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Shift, Qt::NoModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, ratio)));
session.setMarqueeKind(LassoKind::ellipse);
canvas.synchronizeDisplay();
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::replace, ratio)));
session.selectTool(NavigationTool::lasso);
canvas.synchronizeDisplay();
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, ratio)));
session.setLassoKind(LassoKind::polygonal);
canvas.synchronizeDisplay();
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::polygonalLasso, SelectionMode::replace, ratio)));
session.selectTool(NavigationTool::wand);
canvas.synchronizeDisplay();
QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::replace, ratio)));
shown.hover(QPointF(200, 150), Qt::AltModifier);
QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::subtract, ratio)));
// Icons and badges differ; the hot spot is the cross.
QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::replace, 1)));
QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::polygonalLasso, SelectionMode::replace, 1)));
QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::add, 1)) != image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::subtract, 1)));
QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::subtract, 1)));
QCOMPARE(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1).hotSpot(), QPoint(8, 8));
QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)).pixelColor(8, 8).lightness() < 128);
QCOMPARE(CanvasView::wandCursor(SelectionMode::replace, 1).hotSpot(), QPoint(7, 7));
QVERIFY(image(CanvasView::wandCursor(SelectionMode::replace, 1)).pixelColor(20, 20).lightness() < 60);
QCOMPARE(image(CanvasView::wandCursor(SelectionMode::replace, 1)).pixelColor(20, 20).alpha(), 255);
// Over the selection in New mode, the move pointer.
session.selectTool(NavigationTool::marquee);
session.selectAll();
canvas.synchronizeDisplay();
shown.hover(QPointF(200, 150));
QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio)));
shown.hover(QPointF(200, 150), Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::add, ratio)));
shown.press(QPointF(200, 150));
shown.move(QPointF(210, 150), Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio)));
// Space mid-drag leaves the outline its pointer.
QTRY_VERIFY(canvas.hasFocus());
QTest::keyPress(&canvas, Qt::Key_Space);
shown.move(QPointF(220, 150));
QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio)));
QTest::keyRelease(&canvas, Qt::Key_Space);
shown.release(QPointF(220, 150), Qt::ShiftModifier);
QCOMPARE(bounds(session).x(), 20.0);
QVERIFY(image(CanvasView::moveSelectionCursor(1)).pixelColor(17, 16).alpha() > 0);
// Space shows the hand; busy the arrow.
QTRY_VERIFY(canvas.hasFocus());
QTest::keyPress(&canvas, Qt::Key_Space);
QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor);
QTest::keyRelease(&canvas, Qt::Key_Space);
QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio)));
}
void SelectionOverlayTests::theAntsMarchWhileASelectionShowsAndStopWithout()
{
Canvas shown;
EditorSession &session = shown.session;
CanvasView &canvas = *shown.canvas;
session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select");
canvas.synchronizeDisplay();
// Along the top edge the white dashes move.
const auto edge = [&] {
const QImage shot = canvas.grab().toImage();
QString row;
for (int x = 50; x < 150; ++x)
row += shot.pixelColor(x, 50).lightness() > 120 ? 'w' : '.';
return row;
};
const QString first = edge();
QVERIFY(first.contains('w') && first.contains('.'));
QTRY_VERIFY(edge() != first);
// Each tick repaints the outline's rect, nothing more.
QApplication::processEvents();
PaintSpy spy(canvas);
QTRY_VERIFY(spy.count >= 2);
QVERIFY(canvas.findChild<QTimer *>("antsTimer")->isActive());
QVERIFY(spy.painted.contains(QRect(50, 50, 100, 100)));
QVERIFY(QRect(46, 46, 108, 108).contains(spy.painted));
// Without a selection the ants rest; the outline is gone.
session.deselect();
canvas.synchronizeDisplay();
QApplication::processEvents();
const QString cleared = edge();
QVERIFY(!cleared.contains('w'));
const int painted = spy.count;
QTest::qWait(300);
QCOMPARE(spy.count, painted);
QCOMPARE(edge(), cleared);
QVERIFY(!canvas.findChild<QTimer *>("antsTimer")->isActive());
QCOMPARE(canvas.findChild<QTimer *>("antsTimer")->interval(), 120);
// An empty selection shows no ants either.
session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select");
session.applySelection(rectPath(QRectF(0, 0, 400, 300)), SelectionMode::subtract, "Subtract");
canvas.synchronizeDisplay();
QCOMPARE(edge(), cleared);
// Beside the Move box a tick repaints the ants alone.
session.insert(filled(400, 300, qRgba(0, 0, 255, 255), "Blue"));
session.selectTool(NavigationTool::move);
session.applySelection(rectPath(QRectF(50, 50, 20, 20)), SelectionMode::replace, "Select");
canvas.synchronizeDisplay();
QApplication::processEvents();
spy.painted = QRect();
const int before = spy.count;
QTRY_VERIFY(spy.count >= before + 2);
QVERIFY(QRect(46, 46, 28, 28).contains(spy.painted));
}
void SelectionOverlayTests::autoscrollPansTheViewWhileADragHoldsAtTheEdge()
{
Canvas shown;
EditorSession &session = shown.session;
const QSizeF size = shown.documentSize();
const QPointF before = session.viewport.viewPoint(QPointF(0, 0), size);
// Held at the right edge, the view slides.
shown.press(QPointF(100, 100));
shown.move(QPointF(395, 150));
QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() < before.x() - 4);
// Seven points into the margin: 4.8 a tick.
const double slid = before.x() - session.viewport.viewPoint(QPointF(0, 0), size).x();
QVERIFY2(std::abs(std::fmod(slid, 4.8)) < 1e-6 && slid < 4.8 * 6, qPrintable(QString::number(slid)));
QVERIFY(!session.selection().has_value());
const double grown = session.lassoDraft().value().points[2].x();
QVERIFY(grown > 395);
// Far past the edge, forty a tick at most.
shown.move(QPointF(200, 150));
const QPointF held = session.viewport.viewPoint(QPointF(0, 0), size);
shown.move(QPointF(500, 150));
QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() < held.x() - 39);
const double capped = held.x() - session.viewport.viewPoint(QPointF(0, 0), size).x();
QVERIFY2(std::abs(std::fmod(capped, 40)) < 1e-6, qPrintable(QString::number(capped)));
// Back inside, the pan stops where it is.
shown.move(QPointF(200, 150));
QVERIFY(!shown.canvas->findChild<QTimer *>("marqueeAutoscroll")->isActive());
QCOMPARE(shown.canvas->findChild<QTimer *>("marqueeAutoscroll")->interval(), 16);
const QPointF rest = session.viewport.viewPoint(QPointF(0, 0), size);
QTest::qWait(100);
QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), size), rest);
shown.release(QPointF(200, 150));
QVERIFY(session.selection().has_value());
// A dragged outline follows the pan too.
session.selectAll();
shown.press(QPointF(200, 150));
shown.move(QPointF(5, 150));
QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() > rest.x() + 20);
// The outline followed the pointer, then the pan.
QVERIFY(bounds(session).x() < -195);
shown.release(QPointF(5, 150));
QCOMPARE(session.history.undoName(), QString("Move Selection"));
}
void SelectionOverlayTests::theOutlineAndTheDraftAreDrawnWhereTheyLie()
{
Canvas shown;
EditorSession &session = shown.session;
CanvasView &canvas = *shown.canvas;
// The first paint is still pending: let it pass.
QTest::qWait(50);
PaintSpy spy(canvas);
shown.press(QPointF(50, 50));
shown.move(QPointF(150, 100));
QApplication::processEvents();
// The draft's box, black under white, repainted where it lies.
QVERIFY(spy.painted.contains(QRect(50, 50, 100, 50)));
QVERIFY(!spy.painted.contains(QPoint(300, 250)));
QImage shot = canvas.grab().toImage();
// Soft-edged lines mix with the checkerboard's 77 and 89.
const QColor top = shot.pixelColor(100, 50), beside = shot.pixelColor(100, 53);
QVERIFY(top.lightness() > 120 || top.lightness() < 55);
QVERIFY(beside.lightness() > 55 && beside.lightness() < 120);
shown.release(QPointF(150, 100));
QApplication::processEvents();
shot = canvas.grab().toImage();
// Four on, four off: about half the edge is white.
int light = 0;
for (int x = 50; x < 150; ++x)
light += shot.pixelColor(x, 50).lightness() > 120;
QVERIFY2(light >= 40 && light <= 60, qPrintable(QString::number(light)));
// A new outline repaints where it lies, before any tick.
session.deselect();
canvas.synchronizeDisplay();
QApplication::processEvents();
spy.painted = QRect();
session.applySelection(rectPath(QRectF(200, 200, 50, 50)), SelectionMode::replace, "Select");
canvas.synchronizeDisplay();
QApplication::processEvents();
QVERIFY(spy.painted.contains(QRect(200, 200, 50, 50)));
QVERIFY(!spy.painted.contains(QPoint(20, 20)));
// Panned, the outline moves; deselecting repaints where it is.
session.viewport.translate(QSizeF(-100, 0));
session.notify();
canvas.synchronizeDisplay();
QApplication::processEvents();
spy.painted = QRect();
session.deselect();
canvas.synchronizeDisplay();
QApplication::processEvents();
QVERIFY(spy.painted.contains(QRect(100, 200, 50, 50)));
// A polygonal draft draws its first corner's handle.
session.selectTool(NavigationTool::lasso);
session.setLassoKind(LassoKind::polygonal);
shown.click(QPointF(200, 200));
shown.hover(QPointF(250, 220));
QApplication::processEvents();
shot = canvas.grab().toImage();
QVERIFY(shot.pixelColor(200, 200).lightness() > 200);
QVERIFY(shot.pixelColor(196, 200).lightness() < 160);
QVERIFY(shot.pixelColor(193, 200).lightness() < 120);
session.cancelLasso();
}
void SelectionOverlayTests::outlinesAndDraftsAreAntialiased()
{
Canvas shown;
EditorSession &session = shown.session;
// Over mid gray a soft edge leaves other values.
const auto softness = [&] {
QImage sheet(400, 300, QImage::Format_RGB32);
sheet.fill(QColor(128, 128, 128));
QPainter painter(&sheet);
TransformOverlay(session).draw(painter, shown.canvas->palette());
painter.end();
// A white line's partial pixels lie between gray and white.
int soft = 0;
for (int y = 0; y < 300; ++y) {
for (int x = 0; x < 400; ++x) {
const int value = qRed(sheet.pixel(x, y));
soft += value > 128 && value < 255;
}
}
return soft;
};
QPainterPath oval;
oval.addEllipse(QRectF(50, 50, 200, 100));
session.applySelection(oval, SelectionMode::replace, "Select");
QVERIFY(softness() > 100);
session.deselect();
QCOMPARE(softness(), 0);
session.setMarqueeKind(LassoKind::ellipse);
session.beginLasso(QPointF(50, 50), SelectionMode::replace);
session.dragMarquee(QPointF(250, 150), false, false);
QVERIFY(softness() > 100);
session.cancelLasso();
}
void SelectionOverlayTests::theAntsDashesKeepFourOnFourOffAtTwoTimes()
{
Canvas shown;
EditorSession &session = shown.session;
session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select");
// Drawn at 2×: a one-point line fills two device rows.
QImage sheet(800, 600, QImage::Format_RGB32);
sheet.fill(QColor(128, 128, 128));
QPainter painter(&sheet);
painter.scale(2, 2);
TransformOverlay(session).draw(painter, shown.canvas->palette());
painter.end();
QString row;
for (int x = 100; x <= 300; ++x) {
const int value = qRed(sheet.pixel(x, 100));
row += value < 64 ? 'b' : value > 192 ? 'w' : '.';
}
QVERIFY(!row.contains('.'));
// Butt caps: every whole dash and gap is eight pixels.
int runs = 0;
for (int start = 0; start < row.size();) {
int end = start;
while (end < row.size() && row[end] == row[start])
++end;
if (start > 0 && end < row.size()) {
QCOMPARE(end - start, 8);
++runs;
}
start = end;
}
QVERIFY(runs >= 20);
}
void SelectionOverlayTests::openDraftsAndCursorBadgesEndWhereTheyEnd()
{
Canvas shown;
EditorSession &session = shown.session;
session.selectTool(NavigationTool::lasso);
session.beginLasso(QPointF(50, 100), SelectionMode::replace);
session.extendLasso(QPointF(150, 100));
// At 2× over gray the stroke stops at its end.
QImage sheet(800, 600, QImage::Format_RGB32);
sheet.fill(QColor(128, 128, 128));
QPainter painter(&sheet);
painter.scale(2, 2);
TransformOverlay(session).draw(painter, shown.canvas->palette());
painter.end();
QVERIFY(qRed(sheet.pixel(298, 200)) < 64 || qRed(sheet.pixel(298, 200)) > 192);
QCOMPARE(sheet.pixel(300, 200), QColor(128, 128, 128).rgb());
QCOMPARE(sheet.pixel(301, 200), QColor(128, 128, 128).rgb());
QCOMPARE(sheet.pixel(99, 200), QColor(128, 128, 128).rgb());
session.cancelLasso();
// The badges' dashes at 2×: four dark, three light, butt-capped.
const auto whites = [](const QCursor &cursor, int row, int from, int to) {
const QImage badge = image(cursor);
int count = 0;
for (int x = from; x <= to; ++x)
count += badge.pixelColor(x, row).lightness() > 192 && badge.pixelColor(x, row).alpha() > 0;
return count;
};
QVERIFY2(whites(CanvasView::moveSelectionCursor(2), 33, 28, 42) >= 5, "the move badge");
QVERIFY2(whites(CanvasView::loadSelectionCursor(2), 37, 36, 50) >= 5, "the load badge");
}
QTEST_MAIN(SelectionOverlayTests)
#include "SelectionOverlayTests.moc"