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

+460
View File
@@ -0,0 +1,460 @@
#include "DialogDesk.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ProjectStore.h"
#include <QSemaphore>
#include <QTemporaryDir>
#include <QThreadPool>
#include <QtTest>
// The workspace's flows that open dialogs or take time.
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");
// A project of `layers` blank layers saved at `path`.
void store(const QString &path, int layers)
{
EditorSession session;
session.createDocument(8, 6);
for (int index = 0; index < layers; ++index)
session.addBlankLayer();
ProjectStore::save(session.projectSnapshot().value(), path);
}
// Unsaved work in that tab.
void paint(ProjectTab &tab)
{
tab.session.createDocument(8, 6);
tab.session.addBlankLayer();
}
QUrl picture(const QTemporaryDir &folder, const QString &name)
{
QImage image(4, 2, QImage::Format_RGBA8888);
image.fill(Qt::green);
if (!image.save(folder.filePath(name + ".png")))
throw std::runtime_error("the fixture could not be written");
return QUrl::fromLocalFile(folder.filePath(name + ".png"));
}
QStringList titles(const ProjectWorkspace &workspace)
{
QStringList result;
for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs())
result << tab->title() + (tab->id == workspace.selectedID() ? "*" : "");
return result;
}
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;
}
}
class ProjectWorkspaceFlowTests : public QObject {
Q_OBJECT
private slots:
void aProjectOpensInATabOfItsOwn();
void aProjectThatIsOpenIsBroughtToTheFront();
void aProjectThatFailsToOpenLeavesNoTab();
void closingATabAsksAboutItsWork();
void quittingAsksTabByTabAndStopsAtCancel();
void closingTheWindowLeavesOneFreshTab();
void dropsWaitUntilTheWorkspaceIsFree();
void aControllerInATabHandsOverToTheWorkspace();
void aWorkspaceThatEndsMidwayCallsNobody();
void aTabRemovedMidImportStillEndsTheDrop();
void aProjectThatMovedAwayIsNoMatchForAnotherMissingOne();
void everyCallIsWholeWithoutACompletion();
};
void ProjectWorkspaceFlowTests::aProjectOpensInATabOfItsOwn()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 2);
store(folder.filePath("Second.comp"), 1);
ProjectWorkspace workspace;
QWidget window;
workspace.window = &window;
DialogDesk desk;
bool managedMeanwhile = false;
connect(&workspace, &ProjectWorkspace::changed, this, [&] { managedMeanwhile = managedMeanwhile || workspace.isManaging(); });
// The single empty tab makes way for the project.
const QUuid empty = workspace.current().id;
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Trip*"}));
QVERIFY(workspace.current().id != empty && managedMeanwhile && !workspace.isManaging());
QCOMPARE(int(workspace.current().session.document().value().layers.size()), 2);
QVERIFY(workspace.current().controller.workspace == &workspace && workspace.current().controller.window == &window);
// Chosen in a panel, a second project gets its tab.
desk.note = [&] { return QString(workspace.isManaging() ? "managing" : "idle") + (workspace.canSwitch() ? ", free" : ", held"); };
desk.replies = {"<cancel>", folder.filePath("Second.comp")};
QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(false));
QCOMPARE(titles(workspace), (QStringList{"Trip*"}));
QVERIFY(!workspace.isManaging());
QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(true));
QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||managing, held", "panel|Open Project|open|folder|||managing, held"}));
QCOMPARE(titles(workspace), (QStringList{"Trip", "Second*"}));
// An empty tab in front of others is no placeholder.
workspace.newCanvas();
store(folder.filePath("Third.comp"), 1);
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Third.comp"), done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"}));
// While it manages, nothing else begins.
workspace.current().session.setIsProjectBusy(true);
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(false));
QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"}));
}
void ProjectWorkspaceFlowTests::aProjectThatIsOpenIsBroughtToTheFront()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 2);
QVERIFY(QFile::link(folder.filePath("Trip.comp"), folder.filePath("Link.comp")));
ProjectWorkspace workspace;
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(true));
workspace.newCanvas();
QCOMPARE(titles(workspace), (QStringList{"Trip", "Untitled 2*"}));
// By a link, with a slash: still the open project.
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Link.comp") + "/", done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Trip*", "Untitled 2"}));
QVERIFY(!workspace.isManaging());
}
void ProjectWorkspaceFlowTests::aProjectThatFailsToOpenLeavesNoTab()
{
QTemporaryDir folder;
ProjectWorkspace workspace;
const QUuid empty = workspace.current().id;
DialogDesk desk;
desk.replies = {"OK"};
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+"));
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 1);
QCOMPARE(titles(workspace), (QStringList{"Untitled*"}));
QVERIFY(workspace.current().id == empty && !workspace.isManaging());
}
void ProjectWorkspaceFlowTests::closingATabAsksAboutItsWork()
{
ProjectWorkspace workspace;
QWidget window;
workspace.window = &window;
paint(workspace.current());
const QUuid painted = workspace.current().id;
const QUuid blank = workspace.addTab().id;
DialogDesk desk;
desk.note = [&] { return workspace.isManaging() ? QStringLiteral("managing") : QStringLiteral("idle"); };
desk.replies = {"Cancel", "Don’t Save"};
bool done = false;
// A tab behind the front can be closed too.
workspace.close(painted, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen, (QStringList{question.arg("Untitled") + "|managing"}));
QCOMPARE(titles(workspace), (QStringList{"Untitled", "Untitled 2*"}));
QVERIFY(!workspace.isManaging() && workspace.tabs()[0]->controller.window == &window);
done = false;
workspace.close(painted, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(titles(workspace), (QStringList{"Untitled 2*"}));
// Nothing unsaved: no question. A stranger: nothing at all.
done = false;
workspace.close(QUuid::createUuid(), [&] { done = true; });
QTRY_VERIFY(done);
// A busy project in front holds every tab.
const QUuid extra = workspace.addTab(false).id;
workspace.current().session.setIsProjectBusy(true);
done = false;
workspace.close(blank, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(int(workspace.tabs().size()), 2);
workspace.current().session.setIsProjectBusy(false);
workspace.close(extra);
QTRY_COMPARE(int(workspace.tabs().size()), 1);
workspace.close(blank);
QTRY_COMPARE(titles(workspace), (QStringList{"Untitled 4*"}));
QCOMPARE(desk.seen.size(), 2);
QVERIFY(!workspace.isManaging());
}
void ProjectWorkspaceFlowTests::quittingAsksTabByTabAndStopsAtCancel()
{
ProjectWorkspace workspace;
paint(workspace.current());
const QUuid first = workspace.current().id;
paint(workspace.addTab());
const QUuid second = workspace.selectedID();
paint(workspace.addTab());
workspace.select(second);
// The window arrives late: each asked controller still gets it.
QWidget window;
workspace.window = &window;
QUuid announced;
connect(&workspace, &ProjectWorkspace::changed, this, [&] { announced = workspace.selectedID(); });
DialogDesk desk;
// Each tab asked about comes forward and says so.
desk.note = [&] {
return workspace.current().title() + (workspace.isManaging() ? " managing" : " idle")
+ (announced == workspace.selectedID() && workspace.current().controller.window == &window ? "" : " stale");
};
desk.replies = {"Don’t Save", "Cancel"};
QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(false));
QCOMPARE(desk.seen, (QStringList{question.arg("Untitled") + "|Untitled 2 managing", question.arg("Untitled") + "|Untitled managing"}));
QCOMPARE(workspace.selectedID(), first);
QVERIFY(!workspace.isManaging());
desk.replies = {"Don’t Save", "Don’t Save", "Don’t Save"};
QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(true));
QCOMPARE(desk.seen.size(), 5);
QCOMPARE(desk.seen.mid(2).join(";").count("managing"), 3);
QVERIFY(!workspace.isManaging());
// A busy project in front refuses the quit outright.
workspace.current().session.setIsProjectBusy(true);
QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 5);
}
void ProjectWorkspaceFlowTests::closingTheWindowLeavesOneFreshTab()
{
ProjectWorkspace workspace;
paint(workspace.current());
workspace.addTab();
QWidget window;
window.show();
DialogDesk desk;
desk.replies = {"Cancel", "Don’t Save"};
workspace.closeWindow(&window);
QTRY_COMPARE(desk.seen.size(), 1);
QTRY_VERIFY(!workspace.isManaging());
QVERIFY(window.isVisible());
QCOMPARE(int(workspace.tabs().size()), 2);
workspace.closeWindow(&window);
QTRY_VERIFY(!window.isVisible());
QCOMPARE(titles(workspace), (QStringList{"Untitled 3*"}));
QVERIFY(!workspace.current().session.document().has_value());
}
void ProjectWorkspaceFlowTests::dropsWaitUntilTheWorkspaceIsFree()
{
QTemporaryDir folder;
store(folder.filePath("Dropped.comp"), 1);
ProjectWorkspace workspace;
workspace.current().session.setIsProjectBusy(true);
bool done = false;
// Picture, project, picture: the project gets its own tab.
workspace.receive({picture(folder, "One"), QUrl::fromLocalFile(folder.filePath("Dropped.comp")), picture(folder, "Two")},
std::nullopt, QPointF(3, 1), [&] { done = true; });
QTest::qWait(120);
QVERIFY(!done && !workspace.isManaging());
QCOMPARE(titles(workspace), (QStringList{"Untitled*"}));
workspace.current().session.setIsProjectBusy(false);
QTRY_VERIFY(done);
QCOMPARE(titles(workspace), (QStringList{"Untitled", "Dropped", "Untitled 2*"}));
QVERIFY(!workspace.isManaging());
const ImageLayer one = workspace.tabs()[0]->session.document().value().layers.front();
QCOMPARE(one.name, QString("One"));
// The first picture made its canvas: the point is void.
QCOMPARE(one.transform.center(), QPointF(2, 1));
QCOMPARE(workspace.tabs()[2]->session.document().value().layers.front().name, QString("Two"));
// A tab that is gone takes nothing.
done = false;
workspace.receive({picture(folder, "Lost")}, QUuid::createUuid(), std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(int(workspace.tabs().size()), 3);
QVERIFY(!workspace.isManaging());
// Into a named tab, at the point; it comes forward.
done = false;
const std::shared_ptr<ProjectTab> first = workspace.tabs()[0];
QUuid announced;
bool staleAtImport = false;
connect(&workspace, &ProjectWorkspace::changed, this, [&] { announced = workspace.selectedID(); });
connect(&first->session, &EditorSession::changed, this, [&] { staleAtImport = staleAtImport || (first->session.isImporting() && announced != first->id); });
workspace.receive({picture(folder, "Placed")}, first->id, QPointF(3, 1), [&] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!staleAtImport);
QCOMPARE(workspace.selectedID(), first->id);
QCOMPARE(first->session.document().value().layers.back().transform.center(), QPointF(3, 1));
}
void ProjectWorkspaceFlowTests::aControllerInATabHandsOverToTheWorkspace()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 2);
ProjectWorkspace workspace;
paint(workspace.current());
ProjectController &controller = workspace.current().controller;
// Opening never replaces this tab's work: a new tab, unasked.
DialogDesk desk;
QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Trip.comp"), done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip*"}));
QVERIFY(desk.seen.isEmpty());
controller.newCanvas();
QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip", "Untitled 2*"}));
// Its drops land in its own tab, wherever that is.
bool done = false;
controller.receive({picture(folder, "Mine")}, QPointF(3, 1), [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(titles(workspace), (QStringList{"Untitled*", "Trip", "Untitled 2"}));
QCOMPARE(workspace.tabs()[0]->session.document().value().layers.back().name, QString("Mine"));
QCOMPARE(workspace.tabs()[0]->session.document().value().layers.back().transform.center(), QPointF(3, 1));
// So do those of a later tab.
done = false;
workspace.tabs()[2]->controller.receive({picture(folder, "Theirs")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip", "Untitled 2*"}));
QCOMPARE(workspace.tabs()[2]->session.document().value().layers.back().name, QString("Theirs"));
workspace.select(workspace.tabs()[0]->id);
// Closing its window closes the tab, not the window.
QWidget window;
window.show();
desk.replies = {"Don’t Save"};
controller.close(&window);
QTRY_COMPARE(titles(workspace), (QStringList{"Trip*", "Untitled 2"}));
QVERIFY(window.isVisible());
// While the workspace manages, no tab's controller may start.
ProjectController &other = workspace.current().controller;
QVERIFY(other.canStart());
desk.replies = {"<cancel>"};
bool managedAndRefused = false;
connect(&workspace, &ProjectWorkspace::changed, this, [&] { managedAndRefused = managedAndRefused || (workspace.isManaging() && !other.canStart()); });
QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(false));
QVERIFY(managedAndRefused && other.canStart());
}
void ProjectWorkspaceFlowTests::aWorkspaceThatEndsMidwayCallsNobody()
{
QTemporaryDir folder;
store(folder.filePath("Slow.comp"), 1);
const QUrl dropped = picture(folder, "Late");
// Every worker is held: loads and decodes have to wait.
QThreadPool *pool = QThreadPool::globalInstance();
const int workers = pool->maxThreadCount();
QSemaphore held, gate;
const auto letGo = qScopeGuard([&] {
gate.release(workers);
pool->waitForDone();
});
for (int index = 0; index < workers; ++index) {
pool->start([&] {
held.release();
gate.acquire();
});
}
QVERIFY(held.tryAcquire(workers, 10'000));
int calls = 0;
auto loading = std::make_unique<ProjectWorkspace>();
loading->open(folder.filePath("Slow.comp"), [&](bool) { calls += 1; });
auto importing = std::make_unique<ProjectWorkspace>();
const std::shared_ptr<ProjectTab> kept = importing->tabs()[0];
importing->receive({dropped}, std::nullopt, std::nullopt, [&] { calls += 1; });
QTRY_VERIFY(kept->session.isImporting());
loading.reset();
importing.reset();
gate.release(workers);
// The tab someone still holds finishes its import alone.
QTRY_VERIFY(kept->session.document().has_value() && !kept->session.isImporting());
QVERIFY(pool->waitForDone(10'000));
QTest::qWait(100);
QCOMPARE(calls, 0);
}
void ProjectWorkspaceFlowTests::aTabRemovedMidImportStillEndsTheDrop()
{
QTemporaryDir folder;
const QUrl dropped = picture(folder, "Late");
ProjectWorkspace workspace;
const std::weak_ptr<ProjectTab> first = workspace.tabs()[0];
// Every worker is held: the decode has to wait.
QThreadPool *pool = QThreadPool::globalInstance();
const int workers = pool->maxThreadCount();
QSemaphore held, gate;
const auto letGo = qScopeGuard([&] {
gate.release(workers);
pool->waitForDone();
});
for (int index = 0; index < workers; ++index) {
pool->start([&] {
held.release();
gate.acquire();
});
}
QVERIFY(held.tryAcquire(workers, 10'000));
bool done = false;
workspace.receive({dropped}, std::nullopt, std::nullopt, [&] { done = true; });
QTRY_VERIFY(first.lock()->session.isImporting());
// Off the strip, the tab lives until its import ends.
workspace.removeTab(first.lock()->id);
QVERIFY(!first.expired() && workspace.isManaging());
gate.release(workers);
QTRY_VERIFY(done);
QVERIFY(!workspace.isManaging() && workspace.canSwitch());
QTRY_VERIFY(first.expired());
}
void ProjectWorkspaceFlowTests::aProjectThatMovedAwayIsNoMatchForAnotherMissingOne()
{
QTemporaryDir folder;
store(folder.filePath("Here.comp"), 1);
ProjectWorkspace workspace;
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Here.comp"), done); }), std::optional(true));
workspace.newCanvas();
// Its folder moved away: neither place resolves any more.
QVERIFY(QDir().rename(folder.filePath("Here.comp"), folder.filePath("Moved.comp")));
DialogDesk desk;
desk.replies = {"OK"};
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+"));
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Elsewhere.comp"), done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 1);
QCOMPARE(titles(workspace), (QStringList{"Here", "Untitled 2*"}));
// The same missing place is still the same place,
QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("sub/../Here.comp"), done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"}));
// also when named from the folder the process stands in.
workspace.select(workspace.tabs()[1]->id);
QDir::setCurrent(folder.path());
QCOMPARE(answered([&](auto done) { workspace.open(QString("Here.comp"), done); }), std::optional(true));
QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"}));
}
void ProjectWorkspaceFlowTests::everyCallIsWholeWithoutACompletion()
{
QTemporaryDir folder;
store(folder.filePath("Quiet.comp"), 1);
ProjectWorkspace workspace;
// Refused, with nobody to tell: each posted answer is nothing.
workspace.current().session.setIsProjectBusy(true);
workspace.open(folder.filePath("Quiet.comp"));
workspace.confirmQuit({});
workspace.close(workspace.selectedID());
workspace.copyLayer(QUuid::createUuid(), std::nullopt);
QTest::qWait(30);
QCOMPARE(titles(workspace), (QStringList{"Untitled*"}));
workspace.current().session.setIsProjectBusy(false);
// Carried out, with nobody to tell.
workspace.open(folder.filePath("Quiet.comp"));
QTRY_COMPARE(titles(workspace), (QStringList{"Quiet*"}));
QTRY_VERIFY(!workspace.isManaging());
workspace.receive({picture(folder, "Silent")}, workspace.selectedID());
QTRY_COMPARE(int(workspace.current().session.document().value().layers.size()), 2);
QTRY_VERIFY(!workspace.isManaging());
const QUuid id = workspace.current().session.activeLayerID().value();
workspace.copyLayer(id, std::nullopt);
QTRY_COMPARE(int(workspace.tabs().size()), 2);
QTRY_VERIFY(!workspace.isManaging());
QMimeData row;
row.setData(ProjectWorkspace::layerType, uuidString(id).toUtf8());
workspace.select(workspace.tabs()[0]->id);
workspace.receiveProviders(row);
QTRY_COMPARE(int(workspace.tabs().size()), 3);
QTRY_VERIFY(!workspace.isManaging());
workspace.confirmQuit({});
DialogDesk desk;
desk.replies = {"Don’t Save", "Don’t Save", "Don’t Save"};
QTRY_COMPARE(desk.seen.size(), 3);
QTRY_VERIFY(!workspace.isManaging());
QTest::qWait(30);
}
QTEST_MAIN(ProjectWorkspaceFlowTests)
#include "ProjectWorkspaceFlowTests.moc"