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

+213
View File
@@ -0,0 +1,213 @@
#include "ContentView.h"
#include "UI/LayerAppearanceControls.h"
#include "UI/LayersPanel.h"
#include "UI/NativeLayerList.h"
#include <QLabel>
#include <QSettings>
#include <QStackedWidget>
#include <QToolButton>
#include <QtTest>
#include "SessionFixtures.h"
// The Layers panel: heading, rows or empty words, footer, width.
namespace {
template <typename Widget> Widget &find(QWidget &root, const char *name)
{
Widget *found = root.findChild<Widget *>(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(std::string("no widget named ") + name);
return *found;
}
void drag(QWidget &edge, int fromX, int toX)
{
const QPoint start(fromX, 20), end(toX, 20);
QTest::mousePress(&edge, Qt::LeftButton, Qt::NoModifier, start);
QMouseEvent move(QEvent::MouseMove, QPointF(end), edge.mapToGlobal(end), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&edge, &move);
QTest::mouseRelease(&edge, Qt::LeftButton, Qt::NoModifier, end);
}
}
class LayersPanelTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void init();
void theHeadingCountsAndTheEmptyWordsFollowTheDocument();
void theFooterAddsGroupsAndDeletesWithSwiftsWords();
void theEdgeResizesThePanelWithinRangeAndRemembersIt();
void aStoredWidthOutOfRangeIsTheDefault();
void theMaskButtonUsesTheSelection();
};
void LayersPanelTests::initTestCase()
{
// Settings land in ~/.qttest, not the user's own.
QStandardPaths::setTestModeEnabled(true);
}
void LayersPanelTests::init()
{
QSettings().remove(QStringLiteral("layersPanelWidth"));
}
void LayersPanelTests::theHeadingCountsAndTheEmptyWordsFollowTheDocument()
{
EditorSession session;
LayersPanel panel(session);
panel.show();
QVERIFY(QTest::qWaitForWindowExposed(&panel));
auto &count = find<QLabel>(panel, "layerCount");
auto &hint = find<QLabel>(panel, "noLayersHint");
auto &body = find<QStackedWidget>(panel, "layersBody");
QCOMPARE(count.text(), QString("0"));
QCOMPARE(hint.text(), QString("Create a canvas or import an image."));
QVERIFY(!panel.list().isVisible());
session.createDocument(200, 100);
QCOMPARE(hint.text(), QString("Import an image or add a blank layer."));
QCOMPARE(body.currentIndex(), 1);
session.addBlankLayer();
session.addBlankLayer();
QCOMPARE(count.text(), QString("2"));
QCOMPARE(body.currentIndex(), 0);
QVERIFY(panel.list().isVisible());
QCOMPARE(panel.list().cells().size(), size_t(2));
session.deleteSelectedLayers();
session.deleteSelectedLayers();
QCOMPARE(count.text(), QString("0"));
QCOMPARE(body.currentIndex(), 1);
// The appearance controls sit above the rows.
QVERIFY(panel.findChild<LayerAppearanceControls *>() != nullptr);
QVERIFY(panel.findChild<LayerAppearanceControls *>()->y() < body.y());
}
void LayersPanelTests::theFooterAddsGroupsAndDeletesWithSwiftsWords()
{
EditorSession session;
LayersPanel panel(session);
panel.show();
QVERIFY(QTest::qWaitForWindowExposed(&panel));
auto &add = find<QToolButton>(panel, "addBlankLayer");
auto &group = find<QToolButton>(panel, "groupLayers");
auto &remove = find<QToolButton>(panel, "deleteLayer");
auto &mask = find<QToolButton>(panel, "addLayerMask");
QCOMPARE(add.toolTip(), QString("New blank layer (Ctrl+Shift+N)"));
QVERIFY(!add.isEnabled() && !group.isEnabled() && !remove.isEnabled() && !mask.isEnabled());
session.createDocument(200, 100);
QVERIFY(add.isEnabled() && group.isEnabled() && !remove.isEnabled());
QTest::mouseClick(&add, Qt::LeftButton);
QTest::mouseClick(&add, Qt::LeftButton);
QCOMPARE(session.document().value().layers.size(), size_t(2));
QVERIFY(remove.isEnabled() && mask.isEnabled());
QCOMPARE(remove.toolTip(), QString("Delete selected layer"));
QCOMPARE(remove.accessibleName(), QString("Delete selected layer"));
// With a mask selected, Delete takes the mask first.
QTest::mouseClick(&mask, Qt::LeftButton);
QVERIFY(session.activeLayer().value().mask.has_value() && session.isMaskSelected());
QCOMPARE(remove.toolTip(), QString("Delete layer mask"));
QTest::mouseClick(&remove, Qt::LeftButton);
QVERIFY(!session.activeLayer().value().mask.has_value());
QCOMPARE(session.document().value().layers.size(), size_t(2));
// A multi-selection deletes as one step, and says so.
const QUuid top = session.document().value().layers.back().id, bottom = session.document().value().layers.front().id;
session.selectLayers({top, bottom}, top);
QCOMPARE(remove.toolTip(), QString("Delete selected layers"));
QTest::mouseClick(&remove, Qt::LeftButton);
QVERIFY(session.document().value().layers.empty());
QCOMPARE(session.history.undoName(), QString("Delete Layers"));
// A folder from the footer, around the selection.
session.addBlankLayer();
QTest::mouseClick(&group, Qt::LeftButton);
QCOMPARE(session.document().value().layers.size(), size_t(2));
QVERIFY(session.activeLayer().value().isGroup);
// Busy, the footer waits.
session.setIsProjectBusy(true);
QVERIFY(!add.isEnabled() && !group.isEnabled() && !remove.isEnabled() && !mask.isEnabled());
}
void LayersPanelTests::theEdgeResizesThePanelWithinRangeAndRemembersIt()
{
EditorSession session;
session.createDocument(200, 100);
ContentView view(session);
view.resize(1100, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
auto &edge = find<QWidget>(view, "layersPanelEdge");
LayersPanel &panel = view.layersPanel();
QCOMPARE(panel.width(), 252);
QCOMPARE(edge.width(), 8);
QCOMPARE(edge.cursor().shape(), Qt::SplitHCursor);
QCOMPARE(edge.toolTip(), QString("Drag to resize the panel"));
// The panel sits at the right, past the edge.
QCOMPARE(panel.x(), edge.x() + edge.width());
QCOMPARE(panel.x() + panel.width(), view.width());
drag(edge, 4, -36);
QCOMPARE(panel.width(), 292);
QCOMPARE(QSettings().value("layersPanelWidth").toDouble(), 292.0);
// Past the range, the panel stops at its bounds.
drag(edge, 4, -300);
QCOMPARE(panel.width(), int(LayersPanel::maximumWidth));
drag(edge, 4, 400);
QCOMPARE(panel.width(), int(LayersPanel::minimumWidth));
QCOMPARE(QSettings().value("layersPanelWidth").toDouble(), LayersPanel::minimumWidth);
// A right-button drag after the left one moves nothing.
QTest::mousePress(&edge, Qt::RightButton, Qt::NoModifier, QPoint(4, 20));
QMouseEvent move(QEvent::MouseMove, QPointF(-50, 20), edge.mapToGlobal(QPoint(-50, 20)), Qt::NoButton, Qt::RightButton, Qt::NoModifier);
QApplication::sendEvent(&edge, &move);
QTest::mouseRelease(&edge, Qt::RightButton, Qt::NoModifier, QPoint(-50, 20));
QCOMPARE(panel.width(), int(LayersPanel::minimumWidth));
// The next editor starts at the remembered width.
ContentView next(session);
QCOMPARE(next.layersPanel().width(), int(LayersPanel::minimumWidth));
}
void LayersPanelTests::aStoredWidthOutOfRangeIsTheDefault()
{
QSettings().setValue(QStringLiteral("layersPanelWidth"), 9000);
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("layersPanelWidth 9000 is out of range, using 252"));
QCOMPARE(ContentView::layersPanelWidth(), 252.0);
QSettings().setValue(QStringLiteral("layersPanelWidth"), QStringLiteral("wide"));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("layersPanelWidth 0 is out of range, using 252"));
QCOMPARE(ContentView::layersPanelWidth(), 252.0);
}
QTEST_MAIN(LayersPanelTests)
#include "LayersPanelTests.moc"
void LayersPanelTests::theMaskButtonUsesTheSelection()
{
EditorSession session;
session.createDocument(100, 40, true);
QImage image(100, 40, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::red);
session.insert(ImportedImage(image, image, "Red"));
LayersPanel panel(session);
panel.show();
QVERIFY(QTest::qWaitForWindowExposed(&panel));
auto &mask = find<QToolButton>(panel, "addLayerMask");
QCOMPARE(mask.toolTip(), QString("Add layer mask"));
QPainterPath box;
box.addRect(QRectF(20, 10, 30, 20));
session.applySelection(box, SelectionMode::replace, "Select");
QCOMPARE(mask.toolTip(), QString("Add layer mask (the selection becomes black)"));
QTest::mouseClick(&mask, Qt::LeftButton);
QCOMPARE(session.history.undoName(), QString("Add Mask from Selection"));
QVERIFY(!session.selection().has_value() && session.isMaskSelected() && !mask.isEnabled());
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0);
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255);
// The row's menu: black shows only the selection.
session.undo();
QVERIFY(session.selection().has_value());
QAction *black = nullptr;
for (QAction *action : panel.list().cells().at(0)->actions()) {
if (action->objectName() == "addBlackMask")
black = action;
}
QVERIFY(black);
black->trigger();
QCOMPARE(session.history.undoName(), QString("Add Mask from Selection"));
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255);
QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0);
}