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

+305
View File
@@ -0,0 +1,305 @@
#include "DialogDesk.h"
#include "IO/ProjectController.h"
#include "IO/ProjectStore.h"
#include "SessionRecord.h"
#include <QFutureWatcher>
#include <QTemporaryDir>
#include <QtTest>
#include <atomic>
// Opening a project: validated first, asked second, installed last.
namespace {
const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save");
const QString damaged = QStringLiteral("alert|2|Couldn’t open the project|This is not a valid OmaPhoto project, or its metadata is damaged.|OK");
// A canvas with `layers` blank layers, as unsaved work.
void paint(EditorSession &session, int layers)
{
session.createDocument(8, 6);
for (int index = 0; index < layers; ++index)
session.addBlankLayer();
}
// A project of `layers` layers saved at `path`.
void store(const QString &path, int layers)
{
EditorSession session;
paint(session, layers);
ProjectStore::save(session.projectSnapshot().value(), path);
}
std::optional<bool> answered(const std::function<void(std::function<void(bool)>)> &call)
{
std::optional<bool> result;
call([&](bool value) { result = value; });
// An answer that never comes stays empty and fails.
return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt;
}
int layerCount(const EditorSession &session)
{
return int(session.document().value().layers.size());
}
// How often the store read, and the threads it used.
std::atomic<int> loads = 0;
std::atomic<QThread *> loadingThread = nullptr, savingThread = nullptr;
QtMessageHandler forward = nullptr;
void countLoads(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (message.startsWith("loaded ")) {
loads += 1;
loadingThread = QThread::currentThread();
}
if (message.startsWith("saved "))
savingThread = QThread::currentThread();
forward(type, context, message);
}
}
class ProjectOpenTests : public QObject {
Q_OBJECT
private slots:
void init();
void cleanup();
void aSuppliedPathIsInstalledUnasked();
void withoutAPathAFolderIsAskedFor();
void aDamagedOrMissingProjectKeepsTheLiveDocument();
void unsavedWorkIsAskedAboutBeforeItGoes();
void aSaveInTheQuestionReloadsTheVeryProjectBeingOpened();
void onlyASaveOverTheOpenedPlaceReloads();
void theLastSignalOfAnOpenSeesItDone();
void theStoreWorksOffTheCallersThread();
void aTrailingSlashLeavesTheProjectItsName();
};
void ProjectOpenTests::init()
{
loads = 0;
forward = qInstallMessageHandler(countLoads);
}
void ProjectOpenTests::cleanup()
{
qInstallMessageHandler(forward);
}
void ProjectOpenTests::aSuppliedPathIsInstalledUnasked()
{
QTemporaryDir folder;
const QString path = folder.filePath("Three.comp");
store(path, 3);
EditorSession session;
ProjectController controller(session);
DialogDesk desk;
bool busyMeanwhile = false;
connect(&session, &EditorSession::changed, this, [&] { busyMeanwhile = busyMeanwhile || session.isProjectBusy(); });
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
QVERIFY(desk.seen.isEmpty() && busyMeanwhile);
QCOMPARE(layerCount(session), 3);
QCOMPARE(session.projectPath(), std::optional(path));
QVERIFY(!session.isModified() && !session.isProjectBusy());
QCOMPARE(loads, 1);
// Saved work goes unasked as well.
const QString other = folder.filePath("One.comp");
store(other, 1);
QCOMPARE(answered([&](auto done) { controller.open(other, done); }), std::optional(true));
QVERIFY(desk.seen.isEmpty());
QCOMPARE(layerCount(session), 1);
QCOMPARE(session.projectPath(), std::optional(other));
// Each read's watcher goes once it has answered.
QTRY_VERIFY(controller.findChildren<QFutureWatcherBase *>().isEmpty());
}
void ProjectOpenTests::withoutAPathAFolderIsAskedFor()
{
QTemporaryDir folder;
const QString path = folder.filePath("Asked.comp");
store(path, 2);
EditorSession session;
ProjectController controller(session);
DialogDesk desk;
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
// Typed with a slash at its end, as folders are.
desk.replies = {"<cancel>", path + "/"};
const QStringList before = described(session);
QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(false));
QCOMPARE(described(session), before);
QCOMPARE(loads, 0);
QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(true));
QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||busy", "panel|Open Project|open|folder|||busy"}));
QCOMPARE(layerCount(session), 2);
QCOMPARE(session.projectPath(), std::optional(path));
QVERIFY(!session.isProjectBusy());
}
void ProjectOpenTests::aDamagedOrMissingProjectKeepsTheLiveDocument()
{
QTemporaryDir folder;
const QString broken = folder.filePath("Broken.comp");
QVERIFY(QDir().mkpath(broken));
QFile manifest(broken + "/manifest.json");
QVERIFY(manifest.open(QIODevice::WriteOnly));
manifest.write("{ not a manifest");
manifest.close();
EditorSession session;
paint(session, 2);
ProjectController controller(session);
const QStringList before = described(session);
DialogDesk desk;
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
desk.replies = {"OK", "OK"};
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged.");
QCOMPARE(answered([&](auto done) { controller.open(broken, done); }), std::optional(false));
// Validation came first: the unsaved work was never in question.
QCOMPARE(desk.seen, (QStringList{damaged + "|busy"}));
QCOMPARE(described(session), before);
QVERIFY(session.isModified() && !session.isProjectBusy());
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged.");
QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 2);
QCOMPARE(described(session), before);
}
void ProjectOpenTests::unsavedWorkIsAskedAboutBeforeItGoes()
{
QTemporaryDir folder;
const QString incoming = folder.filePath("Incoming.comp"), kept = folder.filePath("Kept.comp");
store(incoming, 3);
EditorSession session;
paint(session, 1);
ProjectController controller(session);
DialogDesk desk;
desk.replies = {"Cancel", "Don’t Save"};
const QStringList before = described(session);
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(false));
QCOMPARE(described(session), before);
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true));
QCOMPARE(desk.seen, (QStringList{question.arg("Untitled"), question.arg("Untitled")}));
QCOMPARE(layerCount(session), 3);
QVERIFY(!session.isModified());
// Save first: the work reaches the disk, then goes.
paint(session, 2);
session.setProjectPath(kept);
desk.replies = {"Save"};
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true));
QCOMPARE(desk.seen.last(), question.arg("Kept.comp"));
QCOMPARE(int(ProjectStore::load(kept).manifest.layers.size()), 2);
QCOMPARE(layerCount(session), 3);
QCOMPARE(session.projectPath(), std::optional(incoming));
QVERIFY(!session.isModified() && !session.isProjectBusy());
}
void ProjectOpenTests::aSaveInTheQuestionReloadsTheVeryProjectBeingOpened()
{
QTemporaryDir folder;
const QString path = folder.filePath("Same.comp"), link = folder.filePath("Link.comp");
store(path, 1);
QVERIFY(QFile::link(path, link));
EditorSession session;
ProjectController controller(session);
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
session.addBlankLayer();
QCOMPARE(layerCount(session), 2);
// Opened again through a link, and saved in the question.
DialogDesk desk;
desk.replies = {"Save"};
loads = 0;
QCOMPARE(answered([&](auto done) { controller.open(link, done); }), std::optional(true));
QCOMPARE(desk.seen, (QStringList{question.arg("Same.comp")}));
// What was read before the save is stale: read again.
QCOMPARE(loads, 2);
QCOMPARE(layerCount(session), 2);
QCOMPARE(session.projectPath(), std::optional(link));
QVERIFY(!session.isModified() && !session.isProjectBusy());
}
void ProjectOpenTests::onlyASaveOverTheOpenedPlaceReloads()
{
QTemporaryDir folder;
const QString first = folder.filePath("First.comp"), second = folder.filePath("Second.comp");
store(first, 1);
store(second, 3);
EditorSession session;
ProjectController controller(session);
QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true));
// The same place, nothing saved: one read is enough.
session.addBlankLayer();
DialogDesk desk;
desk.replies = {"Don’t Save", "Save"};
loads = 0;
QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true));
QCOMPARE(loads, 1);
QCOMPARE(layerCount(session), 1);
// Saved, but to another place than the one opened.
session.addBlankLayer();
loads = 0;
QCOMPARE(answered([&](auto done) { controller.open(second, done); }), std::optional(true));
QCOMPARE(loads, 1);
QCOMPARE(layerCount(session), 3);
QCOMPARE(int(ProjectStore::load(first).manifest.layers.size()), 2);
}
void ProjectOpenTests::theLastSignalOfAnOpenSeesItDone()
{
QTemporaryDir folder;
const QString path = folder.filePath("Seen.comp");
store(path, 2);
EditorSession session;
ProjectController controller(session);
QStringList seen;
connect(&session, &EditorSession::changed, this, [&] { seen = described(session); });
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
QCOMPARE(seen, described(session));
QVERIFY(seen.contains("path " + path) && seen.contains("flags 0000"));
}
void ProjectOpenTests::theStoreWorksOffTheCallersThread()
{
QTemporaryDir folder;
const QString path = folder.filePath("Threads.comp");
store(path, 1);
EditorSession session;
ProjectController controller(session);
loadingThread = savingThread = nullptr;
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
session.addBlankLayer();
QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true));
// The store logs from the thread that did the work.
QVERIFY(loadingThread.load() != nullptr && savingThread.load() != nullptr);
QVERIFY(loadingThread.load() != QThread::currentThread());
QVERIFY(savingThread.load() != QThread::currentThread());
QCOMPARE(controller.thread(), QThread::currentThread());
}
void ProjectOpenTests::aTrailingSlashLeavesTheProjectItsName()
{
QTemporaryDir folder;
const QString path = folder.filePath("Friendly.COMP");
store(path, 1);
EditorSession session;
ProjectController controller(session);
// A shell completes a folder's name with a slash.
QCOMPARE(answered([&](auto done) { controller.open(path + "/", done); }), std::optional(true));
QCOMPARE(session.projectPath(), std::optional(path));
session.addBlankLayer();
DialogDesk desk;
desk.replies = {"Cancel", "<cancel>"};
QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false));
QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(false));
QCOMPARE(desk.seen, (QStringList{question.arg("Friendly.COMP"), "panel|Save Project As|save|file|Friendly.COMP|comp"}));
// Only the end is trimmed: `link/..` keeps its meaning.
QVERIFY(QDir().mkpath(folder.filePath("remote/deep")));
QVERIFY(QFile::link(folder.filePath("remote/deep"), folder.filePath("link")));
store(folder.filePath("Other.comp"), 1);
store(folder.filePath("remote/Other.comp"), 3);
EditorSession linked;
ProjectController opener(linked);
const QString through = folder.filePath("link/../Other.comp");
QCOMPARE(answered([&](auto done) { opener.open(through + "//", done); }), std::optional(true));
QCOMPARE(layerCount(linked), 3);
QCOMPARE(linked.projectPath(), std::optional(through));
}
QTEST_MAIN(ProjectOpenTests)
#include "ProjectOpenTests.moc"