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

+404
View File
@@ -0,0 +1,404 @@
#include "ContentView.h"
#include "DialogDesk.h"
#include "UI/LayersPanel.h"
#include "UI/ToolHeaderStyle.h"
#include <QBuffer>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QTemporaryDir>
#include <QtTest>
// One project's editor area, driven as a user drives it.
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 type(QLineEdit &field, const QString &text)
{
field.setFocus();
field.selectAll();
QTest::keyClicks(&field, text);
}
const std::pair<NavigationTool, const char *> labelled[] = {
{NavigationTool::move, "Move / Transform (V)"},
{NavigationTool::marquee, "Marquee (M)"},
{NavigationTool::lasso, "Lasso (L)"},
{NavigationTool::wand, "Magic Wand (W)"},
{NavigationTool::crop, "Crop (C)"},
{NavigationTool::brush, "Brush (B) · Eraser (E)"},
{NavigationTool::spotHealing, "Spot Healing Brush (J)"},
{NavigationTool::cloneStamp, "Clone Stamp (S) · Alt-click sets the source"},
{NavigationTool::blur, "Smear (R)"},
{NavigationTool::gradient, "Gradient (G)"},
{NavigationTool::shape, "Shape (U) · Shift-U switches Rectangle/Ellipse"},
{NavigationTool::type, "Type (T)"},
{NavigationTool::eyedropper, "Eyedropper (I)"},
{NavigationTool::hand, "Hand (H)"},
{NavigationTool::zoom, "Zoom (Z)"},
};
}
class ContentViewTests : public QObject {
Q_OBJECT
private slots:
void testCreateCanvasAndNavigation();
void theRailHoldsEveryToolInSwiftsOrder();
void theBarFollowsTheTool();
void anImportErrorIsShownUntilItIsDismissed();
void theImporterPicksFilesAndClearsItsFlag();
void openProjectGoesToTheController();
void theCanvasSitsUnderTheWelcomeAndTakesTheKeysWithADocument();
void aShownViewClosesWithoutHearingItsSession();
};
void ContentViewTests::testCreateCanvasAndNavigation()
{
EditorSession session;
ContentView view(session);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
auto &width = find<QLineEdit>(view, "widthInput"), &height = find<QLineEdit>(view, "heightInput");
auto &create = find<QPushButton>(view, "createCanvas");
QVERIFY(width.isVisible() && width.hasFocus());
type(width, "0");
QVERIFY(!create.isEnabled());
type(width, "1200");
type(height, "800");
QTest::mouseClick(&create, Qt::LeftButton);
QCOMPARE(find<QLabel>(view, "canvasDimensions").text(), QString("1,200 × 800 px"));
// The welcome is gone, fields and all.
QVERIFY(view.findChildren<QLineEdit *>("widthInput").isEmpty());
// Swift's welcome makes a project with its first layer.
QCOMPARE(int(session.document().value().layers.size()), 1);
QCOMPARE(session.document().value().layers.front().name, QString("Layer 1"));
session.zoom(1);
QCOMPARE(find<QLabel>(view, "zoomStatus").text(), QString("100%"));
session.zoom(session.viewport.zoom() * 1.25);
QCOMPARE(find<QLabel>(view, "zoomStatus").text(), QString("125%"));
// Layouts settle in the event loop; then the picture holds.
QCoreApplication::processEvents();
QVERIFY(view.grab().save(QStringLiteral("EditorFoundation.png")));
// A project that is cleared brings a fresh welcome back.
auto *copied = new QMimeData;
QImage small(400, 300, QImage::Format_RGBA8888);
QByteArray png;
QBuffer buffer(&png);
small.save(&buffer, "png");
copied->setData("image/png", png);
QApplication::clipboard()->setMimeData(copied);
session.clearProject();
auto &fresh = find<QLineEdit>(view, "widthInput");
QVERIFY(fresh.isVisible());
QCOMPARE(fresh.text(), QString("400"));
QCOMPARE(find<QLineEdit>(view, "heightInput").text(), QString("300"));
QCOMPARE(find<QLabel>(view, "colourStatus").text(), QString("Ready when you are"));
QApplication::clipboard()->clear();
}
void ContentViewTests::theRailHoldsEveryToolInSwiftsOrder()
{
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
view.show();
const QList<QToolButton *> buttons = find<QWidget>(view, "toolRail").findChildren<QToolButton *>();
QCOMPARE(buttons.size(), 15);
QCOMPARE(find<QWidget>(view, "toolRail").width(), 56);
int above = -1;
for (int index = 0; index < 15; ++index) {
QCOMPARE(buttons[index]->toolTip(), QString::fromUtf8(labelled[index].second));
QCOMPARE(buttons[index]->accessibleName(), QString::fromUtf8(labelled[index].second));
QCOMPARE(buttons[index]->size(), QSize(36, 36));
// Top to bottom, in that order.
const int y = buttons[index]->mapTo(&view, QPoint(0, 0)).y();
QVERIFY(y > above);
above = y;
}
// One button is down: the session's tool, however chosen.
for (int index = 0; index < 15; ++index) {
QTest::mouseClick(buttons[index], Qt::LeftButton);
QCOMPARE(session.tool(), labelled[index].first);
for (int other = 0; other < 15; ++other)
QCOMPARE(buttons[other]->isChecked(), other == index);
}
session.selectTool(NavigationTool::crop);
QVERIFY(buttons[4]->isChecked() && !buttons[14]->isChecked());
session.selectTool(NavigationTool::idle);
for (const QToolButton *button : buttons)
QVERIFY(!button->isChecked());
// A refused click leaves the old button down, alone.
session.selectTool(NavigationTool::move);
session.setIsProjectBusy(true);
QTest::mouseClick(buttons[1], Qt::LeftButton);
QCOMPARE(session.tool(), NavigationTool::move);
for (int other = 0; other < 15; ++other)
QCOMPARE(buttons[other]->isChecked(), other == 0);
session.setIsProjectBusy(false);
// A chosen button looks chosen: its plate is painted.
session.selectTool(NavigationTool::move);
const QImage chosen = buttons[0]->grab().toImage(), plain = buttons[1]->grab().toImage();
QVERIFY(chosen.pixelColor(18, 2) != plain.pixelColor(18, 2));
// The Marquee's and the Lasso's icons follow their kinds.
session.setMarqueeKind(LassoKind::ellipse);
QVERIFY(buttons[1]->grab().toImage() != plain);
session.setMarqueeKind(LassoKind::rectangle);
QCOMPARE(buttons[1]->grab().toImage(), plain);
const QImage freehand = buttons[2]->grab().toImage();
session.setLassoKind(LassoKind::polygonal);
QVERIFY(buttons[2]->grab().toImage() != freehand);
// The brush's shows the eraser while it erases.
QToolButton *brush = nullptr;
for (int index = 0; index < 15; ++index) {
if (labelled[index].first == NavigationTool::brush)
brush = buttons[index];
}
QVERIFY(brush);
const QImage painting = brush->grab().toImage();
session.setBrushMode(BrushToolMode::erase);
QVERIFY(brush->grab().toImage() != painting);
session.setBrushMode(BrushToolMode::paint);
QCOMPARE(brush->grab().toImage(), painting);
}
void ContentViewTests::theBarFollowsTheTool()
{
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
view.show();
const auto bar = [&]() -> ToolHeaderBar & { return find<ToolHeaderBar>(view, "toolHeader"); };
// The first tool is Move: its inspector.
QCOMPARE(bar().height(), 42);
QCOMPARE(bar().title->text(), QString("Transform"));
QVERIFY(find<QCheckBox>(view, "transformAutoSelect").isVisible());
QCOMPARE(bar().mapTo(&view, QPoint(0, 0)), QPoint(0, 0));
session.selectTool(NavigationTool::idle);
QCOMPARE(bar().title->text(), QString("Select a tool"));
QCOMPARE(bar().title->font().pixelSize(), 13);
QCOMPARE(bar().height(), 42);
session.selectTool(NavigationTool::eyedropper);
QCOMPARE(bar().title->text(), QString("Eyedropper"));
session.selectTool(NavigationTool::hand);
QCOMPARE(bar().title->text(), QString("Pan"));
QVERIFY(!find<QLineEdit>(view, "zoomPercentage").isVisible());
// Hand and Zoom share one bar, never rebuilt between them.
bar().setProperty("kept", true);
session.selectTool(NavigationTool::zoom);
QVERIFY(bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Zoom"));
QVERIFY(find<QLineEdit>(view, "zoomPercentage").isVisible());
session.selectTool(NavigationTool::brush);
QVERIFY(!bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Brush"));
QVERIFY(find<QLineEdit>(view, "brushSize").isVisible());
// Every brush tool keeps that bar under its own title.
bar().setProperty("kept", true);
session.selectTool(NavigationTool::spotHealing);
QCOMPARE(bar().title->text(), QString("Spot Healing"));
QVERIFY(find<QLineEdit>(view, "brushSize").isVisible());
session.selectTool(NavigationTool::cloneStamp);
QCOMPARE(bar().title->text(), QString("Clone Stamp"));
session.selectTool(NavigationTool::blur);
QCOMPARE(bar().title->text(), QString("Smear"));
QVERIFY(bar().property("kept").toBool());
// Gradient, Shape, Type and Crop have bars of their own.
session.selectTool(NavigationTool::gradient);
QCOMPARE(bar().title->text(), QString("Gradient"));
QVERIFY(find<QToolButton>(view, "gradientLinear").isVisible());
session.selectTool(NavigationTool::shape);
QCOMPARE(bar().title->text(), QString("Shape"));
QVERIFY(find<QToolButton>(view, "shapeRectangle").isVisible());
session.selectTool(NavigationTool::type);
QCOMPARE(bar().title->text(), QString("Type"));
session.selectTool(NavigationTool::crop);
QCOMPARE(bar().title->text(), QString("Crop"));
QVERIFY(find<QComboBox>(view, "cropRatio").isVisible());
QCOMPARE(bar().height(), 42);
QCOMPARE(view.findChildren<ToolHeaderBar *>("toolHeader").size(), 1);
session.selectTool(NavigationTool::blur);
QCOMPARE(bar().title->text(), QString("Smear"));
// The three selection tools share the lasso bar.
session.selectTool(NavigationTool::marquee);
QCOMPARE(bar().title->text(), QString("Marquee"));
QVERIFY(find<QToolButton>(view, "marqueeRectangle").isVisible());
bar().setProperty("kept", true);
session.selectTool(NavigationTool::lasso);
QVERIFY(bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Lasso"));
session.selectTool(NavigationTool::wand);
QVERIFY(bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Magic Wand"));
QCOMPARE(bar().height(), 42);
QCOMPARE(bar().height(), 42);
}
void ContentViewTests::anImportErrorIsShownUntilItIsDismissed()
{
EditorSession session;
ContentView view(session);
view.show();
DialogDesk desk;
desk.replies = {"OK", "OK", "OK"};
// Other news meanwhile opens no second alert.
desk.note = [&] {
session.selectTool(NavigationTool::hand);
return QString::number(view.findChildren<QMessageBox *>().size());
};
session.setImportError(QString("two files could not be read"));
QTRY_COMPARE(desk.seen, (QStringList{"alert|2|Import couldn’t finish|two files could not be read|OK|1"}));
// OK clears the error: file requests may start again.
QTRY_COMPARE(session.importError(), std::nullopt);
session.setBrushError(QString("no pixels"));
QTRY_COMPARE(desk.seen.size(), 2);
QCOMPARE(desk.seen.last(), QString("alert|2|Couldn’t paint|no pixels|OK|1"));
QTRY_COMPARE(session.brushError(), std::nullopt);
session.setCropError(QString("the frame is too large"));
QTRY_COMPARE(desk.seen.size(), 3);
QCOMPARE(desk.seen.last(), QString("alert|2|Couldn’t crop|the frame is too large|OK|1"));
QTRY_COMPARE(session.cropError(), std::nullopt);
// Cleared from elsewhere, the alert goes without being asked.
desk.replies.clear();
session.setImportError(QString("soon gone"));
session.setImportError(std::nullopt);
QTest::qWait(50);
QCOMPARE(desk.seen.size(), 3);
QCOMPARE(view.findChildren<QMessageBox *>().size(), 0);
}
void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
{
QTemporaryDir folder;
QImage picture(4, 2, QImage::Format_RGBA8888);
picture.fill(Qt::green);
QVERIFY(picture.save(folder.filePath("Picked.png")));
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
view.show();
DialogDesk desk;
QImage second(2, 2, QImage::Format_RGBA8888);
second.fill(Qt::blue);
QVERIFY(second.save(folder.filePath("Also.png")));
desk.replies = {"<cancel>", folder.filePath("Picked.png") + "|" + folder.filePath("Also.png")};
// One panel however much else changes; it takes several files.
desk.note = [&] {
session.selectTool(NavigationTool::hand);
const QList<QFileDialog *> panels = view.findChildren<QFileDialog *>();
return QString("%1 %2").arg(panels.size()).arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one");
};
session.setShowsImporter(true);
QTRY_COMPARE(desk.seen.size(), 1);
QTRY_VERIFY(!session.showsImporter());
QVERIFY(session.document().value().layers.empty());
session.setShowsImporter(true);
QTRY_COMPARE(int(session.document().value().layers.size()), 2);
QCOMPARE(session.document().value().layers.front().name, QString("Picked"));
QCOMPARE(session.document().value().layers.back().name, QString("Also"));
QVERIFY(!session.showsImporter());
// A cancelled panel with a file typed imports nothing.
desk.replies = {"<cancel>"};
desk.typeBeforeCancel = folder.filePath("Picked.png");
session.setShowsImporter(true);
QTRY_VERIFY(!session.showsImporter());
QTest::qWait(50);
QCOMPARE(int(session.document().value().layers.size()), 2);
desk.typeBeforeCancel.clear();
QCOMPARE(desk.seen.mid(0, 2), (QStringList{"panel|Open|open|file|||1 many", "panel|Open|open|file|||1 many"}));
QCOMPARE(desk.seen.size(), 3);
// The flag cleared from elsewhere takes the panel down.
desk.replies.clear();
session.setShowsImporter(true);
session.setShowsImporter(false);
QTest::qWait(50);
QCOMPARE(desk.seen.size(), 3);
}
void ContentViewTests::openProjectGoesToTheController()
{
EditorSession session;
ProjectController projects(session);
ContentView view(session, &projects);
view.show();
DialogDesk desk;
desk.replies = {"<cancel>"};
QTest::mouseClick(&find<QPushButton>(view, "openProject"), Qt::LeftButton);
QTRY_COMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder||"}));
// Without a controller the button has nowhere to go.
EditorSession lone;
ContentView bare(lone);
bare.show();
QTest::mouseClick(&find<QPushButton>(bare, "openProject"), Qt::LeftButton);
QTest::qWait(30);
QCOMPARE(desk.seen.size(), 1);
QTest::mouseClick(&find<QPushButton>(bare, "importImage"), Qt::LeftButton);
QVERIFY(lone.showsImporter());
lone.setShowsImporter(false);
}
void ContentViewTests::theCanvasSitsUnderTheWelcomeAndTakesTheKeysWithADocument()
{
EditorSession session;
ContentView view(session);
view.show();
QVERIFY(QTest::qWaitForWindowActive(&view));
auto &canvas = find<CanvasView>(view, "editorCanvas");
QVERIFY(canvas.isVisible());
// The rail and the Layers panel take theirs; 480 stays.
QVERIFY(canvas.width() >= 480 && canvas.height() >= 300);
QCOMPARE(canvas.parentWidget()->x() + canvas.parentWidget()->width(), view.layersPanel().x() - 8);
QCOMPARE(canvas.geometry(), canvas.parentWidget()->rect());
QTRY_VERIFY(find<QLineEdit>(view, "widthInput").hasFocus());
session.createNewProject(300, 200);
QTRY_VERIFY(canvas.hasFocus());
// The viewport follows the canvas; the status shows the fit.
QTRY_COMPARE(session.viewport.viewSize, QSizeF(canvas.size()));
QVERIFY(session.viewport.followsFit());
QCOMPARE(find<QLabel>(view, "zoomStatus").text(), ContentView::percent(session.viewport.zoom()));
// The view syncs the canvas: its cursor follows the tool.
session.selectTool(NavigationTool::hand);
QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor);
// A field keeps the keys through changes; Return returns them.
session.selectTool(NavigationTool::zoom);
auto &zoom = find<QLineEdit>(view, "zoomPercentage");
zoom.setFocus();
QTRY_VERIFY(zoom.hasFocus());
session.zoom(1.5);
QTest::qWait(50);
QVERIFY(zoom.hasFocus());
QTest::keyClick(&zoom, Qt::Key_Return);
QTRY_VERIFY(canvas.hasFocus());
// A cleared project brings the welcome over the canvas again.
session.clearProject();
QVERIFY(canvas.isVisible());
QTRY_VERIFY(find<QLineEdit>(view, "widthInput").hasFocus());
session.createNewProject(300, 200);
QTRY_VERIFY(canvas.hasFocus());
}
void ContentViewTests::aShownViewClosesWithoutHearingItsSession()
{
EditorSession session;
session.createDocument(8, 8);
QSignalSpy changes(&session, &EditorSession::changed);
{
ContentView view(session);
view.show();
QVERIFY(QTest::qWaitForWindowActive(&view));
QTRY_VERIFY(find<CanvasView>(view, "editorCanvas").hasFocus());
changes.clear();
}
// Closing cancelled the brush after the view's members went.
QCOMPARE(changes.count(), 1);
}
QTEST_MAIN(ContentViewTests)
#include "ContentViewTests.moc"