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,227 @@
|
||||
#include "ContentView.h"
|
||||
#include "MenuFixtures.h"
|
||||
#include "UI/LayerIcons.h"
|
||||
#include "UI/LayersPanel.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMenu>
|
||||
#include <QToolButton>
|
||||
#include <QtTest>
|
||||
|
||||
// Adjustment layers in the window: footer, rows, menu, sheets.
|
||||
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;
|
||||
}
|
||||
|
||||
ImportedImage whiteImage(int width, int height)
|
||||
{
|
||||
QImage pixels(width, height, QImage::Format_RGBA8888_Premultiplied);
|
||||
pixels.fill(Qt::white);
|
||||
return ImportedImage(pixels, pixels, QStringLiteral("White"));
|
||||
}
|
||||
|
||||
// A shown label with these words, if any.
|
||||
QLabel *shownLabel(QWidget &root, const QString &words)
|
||||
{
|
||||
for (QLabel *label : root.findChildren<QLabel *>()) {
|
||||
if (label->text() == words && label->isVisible())
|
||||
return label;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool inked(const QImage &image, int x, int y)
|
||||
{
|
||||
return qAlpha(image.pixel(x, y)) > 128;
|
||||
}
|
||||
}
|
||||
|
||||
class AdjustmentPanelTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theFooterAddsEachKindAndItsEditorOpens();
|
||||
void rowsShowTheirGlyphAndOpenOnADoubleClick();
|
||||
void theLayerMenuAddsAndEditsAdjustments();
|
||||
void theSheetsSayTheyReadWhatLiesBeneath();
|
||||
};
|
||||
|
||||
void AdjustmentPanelTests::theFooterAddsEachKindAndItsEditorOpens()
|
||||
{
|
||||
EditorSession session;
|
||||
LayersPanel panel(session);
|
||||
panel.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&panel));
|
||||
auto &button = find<QToolButton>(panel, "addAdjustment");
|
||||
QCOMPARE(button.toolTip(), QString("New adjustment layer"));
|
||||
QCOMPARE(button.accessibleName(), QString("New adjustment layer"));
|
||||
QCOMPARE(button.popupMode(), QToolButton::InstantPopup);
|
||||
QVERIFY(!button.isEnabled());
|
||||
// Swift's circle.lefthalf.filled, in the footer's ink.
|
||||
const QImage icon = LayerIcons::pixmap(LayerIcon::halfFilledCircle, 16, panel.palette().color(QPalette::PlaceholderText), panel.devicePixelRatio()).toImage();
|
||||
QCOMPARE(button.icon().pixmap(QSize(16, 16), panel.devicePixelRatio()).toImage().convertToFormat(icon.format()), icon);
|
||||
// Swift's Menu: the six kinds by name, no ellipsis.
|
||||
const QList<QAction *> entries = button.menu()->actions();
|
||||
QCOMPARE(entries.size(), qsizetype(allAdjustmentKinds.size()));
|
||||
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index)
|
||||
QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index]));
|
||||
session.createDocument(4, 4);
|
||||
session.insert(whiteImage(4, 4));
|
||||
QVERIFY(button.isEnabled());
|
||||
entries[1]->trigger();
|
||||
QCOMPARE(session.activeLayer().value().adjustment.value().kind, AdjustmentKind::levels);
|
||||
// The panel's task opens the editor; the footer rests meanwhile.
|
||||
QTRY_VERIFY(session.levels().has_value());
|
||||
QVERIFY(session.adjustmentOriginal() && !button.isEnabled());
|
||||
session.cancelLevels();
|
||||
QVERIFY(button.isEnabled() && !session.adjustmentEditingID());
|
||||
// A request made again begins again.
|
||||
session.setAdjustmentEditingID(session.activeLayerID());
|
||||
QTRY_VERIFY(session.levels().has_value());
|
||||
session.commitLevels();
|
||||
QTRY_VERIFY(!session.adjustmentEditingID());
|
||||
}
|
||||
|
||||
void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 30);
|
||||
session.insert(whiteImage(40, 30));
|
||||
const QUuid pixels = session.activeLayerID().value();
|
||||
session.addMask();
|
||||
std::vector<QUuid> ids;
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds) {
|
||||
session.addAdjustment(kind);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
ids.push_back(session.activeLayerID().value());
|
||||
}
|
||||
session.addMask();
|
||||
NativeLayerList list(session);
|
||||
list.resize(252, 500);
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowActive(&list));
|
||||
// Top first: the last kind added heads the list.
|
||||
QCOMPARE(list.cells().size(), size_t(7));
|
||||
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index) {
|
||||
LayerCell &row = *list.cells().at(allAdjustmentKinds.size() - 1 - index);
|
||||
const QColor ink = row.palette().color(QPalette::WindowText);
|
||||
QCOMPARE(row.findChild<QLabel *>("layerDimensions")->text(), QString("Adjustment · Double-click to edit"));
|
||||
QCOMPARE(row.thumbnail().size(), QSize(36, 36));
|
||||
const QImage expected = LayerIcons::adjustmentThumbnail(allAdjustmentKinds[index], ink, 1).toImage();
|
||||
QCOMPARE(row.thumbnail().icon().pixmap(QSize(36, 36), 1).toImage().convertToFormat(expected.format()), expected);
|
||||
}
|
||||
// Each kind its own glyph, as Swift's symbols.
|
||||
const std::pair<AdjustmentKind, LayerIcon> symbols[] = {{AdjustmentKind::hsv, LayerIcon::halfFilledCircle}, {AdjustmentKind::levels, LayerIcon::sliders},
|
||||
{AdjustmentKind::curves, LayerIcon::curvePath}, {AdjustmentKind::exposure, LayerIcon::plusMinusCircle},
|
||||
{AdjustmentKind::gradientMap, LayerIcon::paintPalette}, {AdjustmentKind::grain, LayerIcon::circleGrid}};
|
||||
QList<QImage> glyphs;
|
||||
for (const auto &[kind, symbol] : symbols) {
|
||||
QVERIFY(LayerIcons::symbol(kind) == symbol);
|
||||
glyphs << LayerIcons::adjustmentThumbnail(kind, Qt::black, 1).toImage();
|
||||
}
|
||||
for (qsizetype first = 0; first < glyphs.size(); ++first) {
|
||||
for (qsizetype second = first + 1; second < glyphs.size(); ++second)
|
||||
QVERIFY(glyphs[first] != glyphs[second]);
|
||||
}
|
||||
// The glyph is 1.2 times the rail's, centred: 21.6 points.
|
||||
const QImage circle = glyphs.front();
|
||||
QVERIFY(inked(circle, 9, 18) && inked(circle, 26, 18) && !inked(circle, 5, 18) && !inked(circle, 30, 18));
|
||||
// The curve turns a quarter clockwise: its dot moves right.
|
||||
const QImage curve = LayerIcons::adjustmentThumbnail(AdjustmentKind::curves, Qt::black, 1).toImage();
|
||||
const QImage upright = LayerIcons::pixmap(LayerIcon::curvePath, 36, Qt::black, 1).toImage();
|
||||
QVERIFY(inked(curve, 24, 11) && !inked(curve, 11, 11));
|
||||
QVERIFY(inked(upright, 7, 7) && !inked(upright, 29, 7));
|
||||
// The masks: an adjustment's shows no link, the pixels' does.
|
||||
QVERIFY(list.cells().front()->findChild<QToolButton *>("maskLink")->isHidden());
|
||||
QVERIFY(!list.cells().back()->findChild<QToolButton *>("maskLink")->isHidden());
|
||||
// A double click on the glyph asks for its editor.
|
||||
session.selectLayer(pixels);
|
||||
LayerCell &levels = *list.cells().at(allAdjustmentKinds.size() - 2);
|
||||
QTest::mouseDClick(&levels.thumbnail(), Qt::LeftButton);
|
||||
QCOMPARE(session.activeLayerID(), std::optional(ids[1]));
|
||||
QCOMPARE(session.adjustmentEditingID(), std::optional(ids[1]));
|
||||
QVERIFY(!session.renamingLayerID());
|
||||
// While one is asked for, rows refuse the gesture.
|
||||
QTest::mouseDClick(&levels, Qt::LeftButton, Qt::NoModifier, QPoint(levels.width() - 20, 20));
|
||||
QVERIFY(!session.renamingLayerID());
|
||||
// On the name it renames, as every layer.
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
QTest::mouseDClick(&levels, Qt::LeftButton, Qt::NoModifier, QPoint(levels.width() - 20, 20));
|
||||
QCOMPARE(session.renamingLayerID(), std::optional(ids[1]));
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
QTest::keyClick(levels.findChild<QLineEdit *>("layerNameEditor"), Qt::Key_Escape);
|
||||
QVERIFY(!session.renamingLayerID());
|
||||
}
|
||||
|
||||
void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
|
||||
{
|
||||
Bar bar;
|
||||
QAction &adjustments = bar.action("newAdjustmentLayer"), &edit = bar.action("editAdjustment");
|
||||
QCOMPARE(adjustments.text(), QString("New Adjustment Layer"));
|
||||
QCOMPARE(edit.text(), QString("Edit Adjustment…"));
|
||||
QVERIFY(!adjustments.isEnabled() && !edit.isEnabled());
|
||||
const std::pair<const char *, const char *> entries[] = {{"newHueSaturationAdjustment", "Hue/Saturation…"}, {"newLevelsAdjustment", "Levels…"},
|
||||
{"newCurvesAdjustment", "Curves…"}, {"newExposureAdjustment", "Exposure…"},
|
||||
{"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"}};
|
||||
const QList<QAction *> listed = adjustments.menu()->actions();
|
||||
QCOMPARE(listed.size(), qsizetype(std::size(entries)));
|
||||
for (qsizetype index = 0; index < listed.size(); ++index) {
|
||||
QCOMPARE(listed[index]->objectName(), QString(entries[index].first));
|
||||
QCOMPARE(listed[index]->text(), QString(entries[index].second));
|
||||
}
|
||||
bar.session().createDocument(4, 4);
|
||||
bar.session().insert(whiteImage(4, 4));
|
||||
const QUuid pixels = bar.session().activeLayerID().value();
|
||||
QVERIFY(adjustments.isEnabled() && !edit.isEnabled());
|
||||
bar.action("newCurvesAdjustment").trigger();
|
||||
const QUuid curves = bar.session().activeLayerID().value();
|
||||
// The window's panel opens the filter sheet on it.
|
||||
QTRY_VERIFY(bar.session().filterEdit().has_value());
|
||||
QVERIFY(bar.session().filterEdit().value().kind == FilterKind::curves);
|
||||
QVERIFY(!adjustments.isEnabled() && !edit.isEnabled());
|
||||
bar.session().cancelFilter();
|
||||
QVERIFY(adjustments.isEnabled() && edit.isEnabled());
|
||||
edit.trigger();
|
||||
QCOMPARE(bar.session().adjustmentEditingID(), std::optional(curves));
|
||||
QTRY_VERIFY(bar.session().filterEdit().has_value());
|
||||
bar.session().cancelFilter();
|
||||
bar.session().selectLayer(pixels);
|
||||
QVERIFY(adjustments.isEnabled() && !edit.isEnabled());
|
||||
}
|
||||
|
||||
void AdjustmentPanelTests::theSheetsSayTheyReadWhatLiesBeneath()
|
||||
{
|
||||
EditorSession session;
|
||||
ContentView view(session);
|
||||
view.resize(900, 600);
|
||||
view.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&view));
|
||||
session.createDocument(20, 20);
|
||||
session.insert(whiteImage(20, 20));
|
||||
session.selectAll();
|
||||
for (const AdjustmentKind kind : {AdjustmentKind::levels, AdjustmentKind::hsv, AdjustmentKind::grain}) {
|
||||
session.addAdjustment(kind);
|
||||
QTRY_VERIFY(session.adjustmentOriginal().has_value());
|
||||
QTest::qWait(50);
|
||||
const auto shown = [&](const char *words) {
|
||||
for (QWidget *top : QApplication::topLevelWidgets()) {
|
||||
if (top->isVisible() && shownLabel(*top, QString::fromUtf8(words)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// No selection limits a layer's settings.
|
||||
QVERIFY(!shown("Limited to the selection"));
|
||||
if (kind == AdjustmentKind::levels)
|
||||
QVERIFY(shown("Underlying pixels · alpha-weighted histogram"));
|
||||
session.finishAdjustmentEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(AdjustmentPanelTests)
|
||||
#include "AdjustmentPanelTests.moc"
|
||||
Reference in new issue
Block a user