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

+391
View File
@@ -0,0 +1,391 @@
#include "DialogDesk.h"
#include "IO/ProjectStore.h"
#include "UI/ProjectWorkspaceView.h"
#include <QLineEdit>
#include <QProcess>
#include <QScrollBar>
#include <QPushButton>
#include <QTemporaryDir>
#include <QToolButton>
#include <QtTest>
// The window: tabs, the toolbar, closing and quitting.
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;
}
QAction &action(QWidget &root, const char *name)
{
QAction *found = root.findChild<QAction *>(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(std::string("no action named ") + name);
return *found;
}
QStringList titles(const ProjectTabStrip &strip)
{
QStringList result;
for (const ProjectTabButton *button : strip.buttons())
result << button->findChild<QToolButton *>("selectTab")->text();
return result;
}
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);
}
}
class ProjectWorkspaceViewTests : public QObject {
Q_OBJECT
private slots:
void testCreateCanvasAndNavigation();
void theFrontTabHasTheEditorAndTheWindow();
void tabsShowTheirTitlesDotsAndState();
void tabButtonsSelectAndClose();
void theToolbarFollowsTheFrontSession();
void closingTheWindowAsksThroughTheWorkspace();
void aReplacedTabOutlivesItsEditor();
void filesAtLaunchGoToTheWorkspace();
};
void ProjectWorkspaceViewTests::testCreateCanvasAndNavigation()
{
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
auto &width = find<QLineEdit>(window, "widthInput");
width.setFocus();
width.selectAll();
QTest::keyClicks(&width, "0");
QVERIFY(!find<QPushButton>(window, "createCanvas").isEnabled());
width.selectAll();
QTest::keyClicks(&width, "1200");
auto &height = find<QLineEdit>(window, "heightInput");
height.setFocus();
height.selectAll();
QTest::keyClicks(&height, "800");
QTest::mouseClick(&find<QPushButton>(window, "createCanvas"), Qt::LeftButton);
QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("1,200 × 800 px"));
workspace.current().session.zoom(0.5);
action(window, "actualPixels").trigger();
QCOMPARE(find<QLabel>(window, "zoomStatus").text(), QString("100%"));
action(window, "zoomIn").trigger();
QCOMPARE(find<QLabel>(window, "zoomStatus").text(), QString("125%"));
action(window, "fitCanvas").trigger();
QVERIFY(workspace.current().session.viewport.followsFit());
QCoreApplication::processEvents();
QVERIFY(window.grab().save(QStringLiteral("EditorFoundation.png")));
// New canvas opens a tab unasked; the canvas stays behind.
action(window, "newCanvasToolbar").trigger();
QCOMPARE(int(workspace.tabs().size()), 2);
QVERIFY(find<QLineEdit>(window, "widthInput").isVisible());
workspace.select(workspace.tabs()[0]->id);
QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("1,200 × 800 px"));
}
void ProjectWorkspaceViewTests::theFrontTabHasTheEditorAndTheWindow()
{
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
ProjectTab &first = workspace.current();
QVERIFY(workspace.window == &window && first.controller.window == &window);
QVERIFY(window.content() && window.centralWidget() == window.content());
window.content()->setProperty("first", true);
// A second tab in front: a fresh editor for it.
first.session.createDocument(8, 8);
workspace.newCanvas();
ProjectTab &second = workspace.current();
QVERIFY(!window.content()->property("first").toBool() && window.centralWidget() == window.content());
QVERIFY(second.controller.window == &window);
QVERIFY(find<QLineEdit>(window, "widthInput").isVisible());
// Back to the first: its canvas, not the welcome.
workspace.select(first.id);
QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("8 × 8 px"));
QVERIFY(window.findChildren<QLineEdit *>("widthInput").isEmpty());
// Managing dims the editor; the tabs stay live.
QVERIFY(window.content()->isEnabled());
DialogDesk desk;
desk.note = [&] { return window.content()->isEnabled() ? QStringLiteral("enabled") : QStringLiteral("dimmed"); };
desk.replies = {"<cancel>"};
bool done = false;
workspace.open(std::nullopt, [&](bool) { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||dimmed"}));
QVERIFY(window.content()->isEnabled());
}
void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 1);
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
ProjectTabStrip &strip = *window.tabs();
QCOMPARE(strip.height(), 34);
QCOMPARE(strip.accessibleName(), QString("Project tabs"));
// The strip paints no stripe over the toolbar.
QVERIFY(QTest::qWaitForWindowExposed(&window));
const QImage bar = window.grab().toImage();
const QPoint inStrip = strip.mapTo(&window, QPoint(strip.width() - 4, 3)), beside = strip.mapTo(&window, QPoint(strip.width() + 8, 3));
QCOMPARE(bar.pixelColor(inStrip), bar.pixelColor(beside));
QCOMPARE(titles(strip), (QStringList{"Untitled"}));
workspace.current().session.createDocument(8, 8);
// Unsaved work shows as a dot before the title.
QCOMPARE(titles(strip), (QStringList{"● Untitled"}));
bool done = false;
workspace.open(folder.filePath("Trip.comp"), [&](bool) { done = true; });
QTRY_VERIFY(done);
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"}));
const QList<ProjectTabButton *> buttons = strip.buttons();
QCOMPARE(buttons.size(), 2);
QVERIFY(!buttons[0]->isActive() && buttons[1]->isActive());
// Swift's capsule: 28 high, title 35 to 155, cross 16.
QCOMPARE(buttons[0]->height(), 28);
QCOMPARE(buttons[0]->sizeHint().height(), 28);
QCOMPARE(buttons[0]->findChild<QToolButton *>("closeTab")->size(), QSize(16, 28));
// Titles stop at 155 and start at 35.
QToolButton &title = *buttons[0]->findChild<QToolButton *>("selectTab");
workspace.tabs()[0]->session.setProjectPath(QString("/x/") + QString(80, QLatin1Char('W')) + ".comp");
QTRY_COMPARE(title.width(), 155);
QCOMPARE(buttons[0]->height(), 28);
workspace.tabs()[0]->session.setProjectPath(QString("/x/I.comp"));
QTRY_COMPARE(title.width(), 35);
workspace.tabs()[0]->session.setProjectPath(std::nullopt);
QCOMPARE(buttons[1]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::DemiBold);
QCOMPARE(buttons[0]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::Medium);
QCOMPARE(buttons[1]->findChild<QToolButton *>("closeTab")->toolTip(), QString("Close Trip"));
QCOMPARE(buttons[1]->findChild<QToolButton *>("closeTab")->accessibleName(), QString("Close Trip"));
// The front tab is painted brighter, at once.
const QImage front = buttons[1]->grab().toImage(), behind = buttons[0]->grab().toImage();
QVERIFY(front.pixelColor(3, 14) != behind.pixelColor(3, 14));
workspace.select(workspace.tabs()[0]->id);
QCOMPARE(buttons[0]->grab().toImage().pixelColor(3, 14), front.pixelColor(3, 14));
QCOMPARE(buttons[1]->grab().toImage().pixelColor(3, 14), behind.pixelColor(3, 14));
workspace.select(workspace.tabs()[1]->id);
// A dot in a tab behind shows there at once.
workspace.tabs()[0]->session.history.markSaved();
workspace.tabs()[0]->session.notify();
QCOMPARE(titles(strip), (QStringList{"Untitled", "Trip"}));
workspace.tabs()[0]->session.addBlankLayer();
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"}));
// A busy front holds every button but the front's own.
workspace.current().session.setIsProjectBusy(true);
QVERIFY(!buttons[0]->findChild<QToolButton *>("selectTab")->isEnabled());
QVERIFY(buttons[1]->findChild<QToolButton *>("selectTab")->isEnabled());
QVERIFY(!buttons[0]->findChild<QToolButton *>("closeTab")->isEnabled() && !buttons[1]->findChild<QToolButton *>("closeTab")->isEnabled());
workspace.current().session.setIsProjectBusy(false);
QVERIFY(buttons[0]->findChild<QToolButton *>("selectTab")->isEnabled() && buttons[1]->findChild<QToolButton *>("closeTab")->isEnabled());
// Buttons stay: the same widget for the same tab.
buttons[0]->setProperty("kept", true);
workspace.addTab(false);
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip", "Untitled 2"}));
QVERIFY(strip.buttons()[0]->property("kept").toBool());
workspace.removeTab(workspace.tabs()[1]->id);
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2"}));
QVERIFY(strip.buttons()[0]->property("kept").toBool());
QCOMPARE(strip.buttons().size(), 2);
// A tab made behind shows its title without a nudge.
ProjectTab &late = workspace.addTab(false);
late.session.setProjectPath(QString("/x/Late.comp"));
workspace.select(workspace.tabs()[0]->id);
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"}));
// More tabs than fit: the front one scrolls into view.
QVERIFY(QTest::qWaitForWindowExposed(&window));
for (int index = 0; index < 8; ++index)
workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index));
const ProjectTabButton *last = strip.buttons().last();
const auto wholeInView = [&](const ProjectTabButton *button) {
return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size()));
};
// In view at once, and still after the layout settles.
QVERIFY(last->tab->id == workspace.selectedID() && wholeInView(last));
QVERIFY(strip.widget()->width() > strip.viewport()->width());
QVERIFY(last->findChild<QToolButton *>("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0);
QCoreApplication::processEvents();
QVERIFY(wholeInView(last));
// The front title grows: the scroll follows the layout.
workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp");
QCoreApplication::processEvents();
QCoreApplication::processEvents();
QVERIFY(last->width() > 180 && wholeInView(last));
workspace.select(workspace.tabs()[0]->id);
const ProjectTabButton *head = strip.buttons().first();
QVERIFY(wholeInView(head));
QCoreApplication::processEvents();
QVERIFY(wholeInView(head));
}
void ProjectWorkspaceViewTests::tabButtonsSelectAndClose()
{
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
const QUuid first = workspace.current().id;
workspace.current().session.createDocument(8, 8);
workspace.newCanvas();
const QUuid second = workspace.current().id;
const QList<ProjectTabButton *> buttons = window.tabs()->buttons();
QTest::mouseClick(buttons[0]->findChild<QToolButton *>("selectTab"), Qt::LeftButton);
QCOMPARE(workspace.selectedID(), first);
QTest::mouseClick(buttons[1]->findChild<QToolButton *>("selectTab"), Qt::LeftButton);
QCOMPARE(workspace.selectedID(), second);
// Closing the first asks; Don't Save removes it.
DialogDesk desk;
desk.replies = {"Don’t Save"};
QTest::mouseClick(buttons[0]->findChild<QToolButton *>("closeTab"), Qt::LeftButton);
QTRY_COMPARE(int(workspace.tabs().size()), 1);
QCOMPARE(workspace.selectedID(), second);
QCOMPARE(titles(*window.tabs()), (QStringList{"Untitled 2"}));
}
void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession()
{
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
QAction &fit = action(window, "fitCanvas"), &actual = action(window, "actualPixels");
QAction &in = action(window, "zoomIn"), &out = action(window, "zoomOut"), &fresh = action(window, "newCanvasToolbar");
QCOMPARE(fit.toolTip(), QString("Fit canvas in window (Ctrl+0)"));
QCOMPARE(actual.toolTip(), QString("Actual pixels (Ctrl+1)"));
QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl++)"));
QCOMPARE(out.toolTip(), QString("Zoom out (Ctrl+−)"));
QCOMPARE(fresh.toolTip(), QString("New canvas (Ctrl+N) · Drop images here for new tabs"));
// No canvas: the zooms wait; New canvas is ready.
QVERIFY(!fit.isEnabled() && !actual.isEnabled() && !in.isEnabled() && !out.isEnabled() && fresh.isEnabled());
EditorSession &session = workspace.current().session;
session.createDocument(400, 300);
QVERIFY(fit.isEnabled() && actual.isEnabled() && in.isEnabled() && out.isEnabled());
actual.trigger();
out.trigger();
QCOMPARE(session.viewport.zoom(), 0.8);
in.trigger();
in.trigger();
QCOMPARE(session.viewport.zoom(), 1.25);
// Importing or a busy spell holds New canvas.
session.setIsImporting(true);
QVERIFY(!fresh.isEnabled());
session.setIsImporting(false);
session.setIsProjectBusy(true);
QVERIFY(fresh.isEnabled());
QTRY_VERIFY(!fresh.isEnabled());
session.setIsProjectBusy(false);
QVERIFY(fresh.isEnabled());
// The window carries the project's state for the frame.
QVERIFY(window.isWindowModified());
QCOMPARE(window.windowTitle(), QString("Untitled[*]"));
session.setProjectPath(QString("/somewhere/Trip.comp"));
QCOMPARE(window.windowFilePath(), QString("/somewhere/Trip.comp"));
QCOMPARE(window.windowTitle(), QString("Trip[*]"));
session.history.markSaved();
session.notify();
QVERIFY(!window.isWindowModified());
// Another tab in front: the toolbar follows that session.
workspace.newCanvas();
QVERIFY(!fit.isEnabled() && !window.isWindowModified());
QCOMPARE(window.windowFilePath(), QString());
QCOMPARE(window.windowTitle(), QString("Untitled 2[*]"));
// At its smallest the window still shows the whole editor.
window.resize(800, 520);
QTRY_COMPARE(window.size(), QSize(800, 520));
QWidget &status = find<QWidget>(window, "statusBar");
QTRY_VERIFY(window.rect().contains(status.mapTo(&window, status.rect().bottomRight())));
}
void ProjectWorkspaceViewTests::closingTheWindowAsksThroughTheWorkspace()
{
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
workspace.current().session.createDocument(8, 8);
DialogDesk desk;
desk.replies = {"Cancel", "Don’t Save"};
// Cancel keeps the window and its tab.
window.close();
QTRY_COMPARE(desk.seen.size(), 1);
QTRY_VERIFY(!workspace.isManaging());
QVERIFY(window.isVisible());
QVERIFY(workspace.current().session.document().has_value());
// Don't Save closes it and leaves one fresh tab behind.
window.close();
QTRY_VERIFY(!window.isVisible());
QCOMPARE(int(workspace.tabs().size()), 1);
QVERIFY(!workspace.current().session.document().has_value());
// Nothing unsaved: no question at all.
window.show();
window.close();
QTRY_VERIFY(!window.isVisible());
QCOMPARE(desk.seen.size(), 2);
// Shown again with new work, it asks again.
window.show();
workspace.current().session.createDocument(8, 8);
desk.replies = {"Cancel"};
window.close();
QTRY_COMPARE(desk.seen.size(), 3);
QTRY_VERIFY(!workspace.isManaging());
QVERIFY(window.isVisible());
}
void ProjectWorkspaceViewTests::aReplacedTabOutlivesItsEditor()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 1);
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.show();
// The project replaces the empty tab; its editor goes first.
const std::weak_ptr<ProjectTab> untitled = workspace.tab(workspace.current().id);
std::optional<bool> aliveAtTheEnd;
connect(window.content(), &QObject::destroyed, this, [&] { aliveAtTheEnd = !untitled.expired(); });
bool opened = false;
workspace.open(folder.filePath("Trip.comp"), [&](bool done) { opened = done; });
QTRY_VERIFY(opened);
QVERIFY(aliveAtTheEnd.value());
QVERIFY(untitled.expired());
QCOMPARE(int(workspace.tabs().size()), 1);
QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("8 × 6 px"));
}
void ProjectWorkspaceViewTests::filesAtLaunchGoToTheWorkspace()
{
QTemporaryDir folder;
store(folder.filePath("Trip.comp"), 2);
QImage picture(4, 2, QImage::Format_RGBA8888);
picture.fill(Qt::green);
QVERIFY(picture.save(folder.filePath("Picture.png")));
// The real app opens its arguments; the log says so.
QProcess app;
app.setProcessChannelMode(QProcess::MergedChannels);
// A Qt built for journald logs there unless told otherwise.
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert("QT_FORCE_STDERR_LOGGING", "1");
app.setProcessEnvironment(environment);
app.start(QCoreApplication::applicationDirPath() + "/omaphoto", {folder.filePath("Trip.comp"), folder.filePath("Picture.png")});
QVERIFY(app.waitForStarted(10'000));
QByteArray log;
QTRY_VERIFY2_WITH_TIMEOUT((log += app.readAll()).contains("imported a request of 1 files"), log.constData(), 30'000);
app.kill();
app.waitForFinished(10'000);
const qsizetype opened = log.indexOf("installed a project of 2 layers from " + folder.filePath("Trip.comp").toUtf8());
const qsizetype imported = log.indexOf("imported " + folder.filePath("Picture.png").toUtf8() + " 4 x 2");
// Both arrived, the project first, as they were named.
QVERIFY2(opened >= 0 && imported > opened, log.constData());
QVERIFY2(log.contains("OmaPhoto 1.0.0 on Qt"), log.constData());
}
QTEST_MAIN(ProjectWorkspaceViewTests)
#include "ProjectWorkspaceViewTests.moc"