OmaPhoto 1.0.0: the Linux port of Compositor
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:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user