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>
236 lines
11 KiB
C++
236 lines
11 KiB
C++
#include "CanvasFixtures.h"
|
|
#include "Rendering/TransformOverlay.h"
|
|
#include "UI/TransformInspector.h"
|
|
|
|
// A distortion on the canvas: box, drags, cursor, preview.
|
|
namespace {
|
|
QImage image(const QCursor &cursor)
|
|
{
|
|
return cursor.pixmap().toImage();
|
|
}
|
|
|
|
void hover(QWidget &widget, QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier)
|
|
{
|
|
QMouseEvent event(QEvent::MouseMove, at, at, widget.mapToGlobal(at.toPoint()), Qt::NoButton, Qt::NoButton, modifiers);
|
|
QApplication::sendEvent(&widget, &event);
|
|
}
|
|
|
|
// The document fills the canvas: view points are pixels.
|
|
struct Canvas : Shown {
|
|
QUuid red;
|
|
Canvas() : Shown(QSize(400, 300), QSize(400, 300))
|
|
{
|
|
settle();
|
|
session.zoom(1);
|
|
session.insert(filled(100, 100, qRgba(255, 0, 0, 255), "Red"));
|
|
red = session.activeLayerID().value();
|
|
session.selectTool(NavigationTool::move);
|
|
canvas->synchronizeDisplay();
|
|
}
|
|
void press(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mousePress(canvas, Qt::LeftButton, modifiers, at.toPoint()); }
|
|
void move(QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier)
|
|
{
|
|
QMouseEvent event(QEvent::MouseMove, to, to, canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, modifiers);
|
|
QApplication::sendEvent(canvas, &event);
|
|
}
|
|
void release(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mouseRelease(canvas, Qt::LeftButton, modifiers, at.toPoint()); }
|
|
bool shows(const QCursor &cursor) const { return image(canvas->cursor()) == image(cursor); }
|
|
};
|
|
}
|
|
|
|
class DistortCanvasTests : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void aDistortionsGeometryHasNoGripAndHitsItsCornersAndEdges();
|
|
void ctrlDraggingAHandleDistortsAndKeepsDistorting();
|
|
void theDistortCursorShowsWithCtrlOrOnceDistorted();
|
|
void thePreviewIsDrawnAndTheNumbersRest();
|
|
void aDistortedLayerShowsThroughItsMask();
|
|
};
|
|
|
|
void DistortCanvasTests::aDistortionsGeometryHasNoGripAndHitsItsCornersAndEdges()
|
|
{
|
|
CanvasViewport viewport;
|
|
const QSizeF size(400, 300);
|
|
viewport.resize(QSizeF(400, 300), 1, size);
|
|
viewport.setZoom(1, QPointF(200, 150), size);
|
|
QCOMPARE(viewport.viewPoint(QPointF(0, 0), size), QPointF(0, 0));
|
|
const Corners corners = {QPointF(100, 100), QPointF(200, 100), QPointF(180, 160), QPointF(100, 160)};
|
|
const TransformOverlayGeometry geometry(corners, viewport, size);
|
|
QVERIFY(!geometry.showsRotation);
|
|
QCOMPARE(geometry.handles[0], QPointF(100, 100));
|
|
QCOMPARE(geometry.handles[1], QPointF(150, 100));
|
|
QCOMPARE(geometry.handles[3], QPointF(190, 130));
|
|
QCOMPARE(geometry.handles[6], QPointF(100, 160));
|
|
QCOMPARE(geometry.handles[7], QPointF(100, 130));
|
|
QCOMPARE(geometry.rotationHandle, geometry.handles[1]);
|
|
// The top's middle is a handle, not the grip.
|
|
const TransformDrag::Mode top = geometry.hit(QPointF(150, 100)).value();
|
|
QVERIFY(top.kind == TransformDrag::Kind::resize);
|
|
QCOMPARE(top.index, 1);
|
|
QCOMPARE(geometry.hit(QPointF(185, 145)).value().index, 3);
|
|
QVERIFY(!geometry.hit(QPointF(150, 130)).has_value());
|
|
// A box's geometry keeps its grip; the two differ.
|
|
const TransformOverlayGeometry box({.origin = {100, 100}, .size = {100, 60}}, viewport, size);
|
|
QVERIFY(box.showsRotation);
|
|
QVERIFY(!(box == geometry));
|
|
}
|
|
|
|
void DistortCanvasTests::ctrlDraggingAHandleDistortsAndKeepsDistorting()
|
|
{
|
|
Canvas shown;
|
|
EditorSession &session = shown.session;
|
|
const double ratio = shown.canvas->devicePixelRatio();
|
|
// Ctrl on the bottom right corner: that corner alone follows.
|
|
shown.press(QPointF(250, 200), Qt::ControlModifier);
|
|
QVERIFY(shown.shows(CanvasView::distortCursor(ratio)));
|
|
shown.move(QPointF(260, 210), Qt::ControlModifier);
|
|
shown.move(QPointF(270, 230), Qt::ControlModifier);
|
|
const TransformEdit edit = session.transformEdit().value();
|
|
QVERIFY(edit.persistent);
|
|
QCOMPARE(edit.corners.value(), (Corners{QPointF(150, 100), QPointF(250, 100), QPointF(270, 230), QPointF(150, 200)}));
|
|
QCOMPARE(edit.draft, layerWith(session, shown.red).transform);
|
|
shown.release(QPointF(270, 230), Qt::ControlModifier);
|
|
// The edit waits for Apply; its box has no grip.
|
|
QVERIFY(session.transformEdit().has_value());
|
|
const TransformOverlay overlay(session);
|
|
QVERIFY(!overlay.geometry().value().showsRotation);
|
|
QCOMPARE(overlay.geometry().value().handles[4], QPointF(270, 230));
|
|
// Once distorted, plain drags distort too; Shift keeps an axis.
|
|
shown.press(QPointF(150, 100));
|
|
QVERIFY(shown.shows(CanvasView::distortCursor(ratio)));
|
|
shown.move(QPointF(140, 105), Qt::ShiftModifier);
|
|
shown.release(QPointF(140, 105), Qt::ShiftModifier);
|
|
QCOMPARE(session.transformEdit().value().corners.value()[0], QPointF(140, 100));
|
|
// An edge handle moves both its corners.
|
|
shown.press(QPointF(198, 100));
|
|
shown.move(QPointF(198, 90));
|
|
shown.release(QPointF(198, 90));
|
|
QCOMPARE(session.transformEdit().value().corners.value()[0], QPointF(140, 90));
|
|
QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(250, 90));
|
|
// Return applies: the pixels are resampled into the shape's bounds.
|
|
QTRY_VERIFY(shown.canvas->hasFocus());
|
|
QTest::keyClick(shown.canvas, Qt::Key_Return);
|
|
QVERIFY(!session.transformEdit().has_value());
|
|
QCOMPARE(session.history.undoName(), QString("Distort"));
|
|
QCOMPARE(layerWith(session, shown.red).transform, (LayerTransform{.origin = {140, 90}, .size = {130, 140}}));
|
|
QCOMPARE(layerWith(session, shown.red).asset.value().size(), QSize(130, 140));
|
|
session.undo();
|
|
QCOMPARE(layerWith(session, shown.red).transform.size, QSizeF(100, 100));
|
|
}
|
|
|
|
void DistortCanvasTests::theDistortCursorShowsWithCtrlOrOnceDistorted()
|
|
{
|
|
Canvas shown;
|
|
CanvasView &canvas = *shown.canvas;
|
|
const double ratio = canvas.devicePixelRatio();
|
|
hover(canvas, QPointF(150, 100));
|
|
QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor);
|
|
hover(canvas, QPointF(150, 100), Qt::ControlModifier);
|
|
QVERIFY(shown.shows(CanvasView::distortCursor(ratio)));
|
|
// Ctrl off a handle picks: the move pointer stays.
|
|
hover(canvas, QPointF(200, 150), Qt::ControlModifier);
|
|
QVERIFY(shown.shows(CanvasView::moveCursor(ratio)));
|
|
hover(canvas, QPointF(150, 100));
|
|
QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor);
|
|
shown.session.beginTransform();
|
|
shown.session.beginDistort();
|
|
canvas.synchronizeDisplay();
|
|
QVERIFY(shown.shows(CanvasView::distortCursor(ratio)));
|
|
// The white arrow: light where the duplicate pointer is dark.
|
|
QVERIFY(image(CanvasView::distortCursor(1)).pixelColor(8, 12).lightness() > 128);
|
|
QVERIFY(image(CanvasView::duplicateCursor(1)).pixelColor(8, 12).lightness() < 128);
|
|
shown.session.cancelTransform();
|
|
canvas.synchronizeDisplay();
|
|
QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor);
|
|
}
|
|
|
|
void DistortCanvasTests::thePreviewIsDrawnAndTheNumbersRest()
|
|
{
|
|
Shown shown;
|
|
CanvasView &canvas = *shown.canvas;
|
|
EditorSession &session = shown.session;
|
|
shown.settle();
|
|
session.zoom(1);
|
|
session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"), QPointF(50, 50));
|
|
QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100));
|
|
QVERIFY(canvas.synchronizeDisplay());
|
|
TransformInspector inspector(session);
|
|
QWidget &numbers = *inspector.findChild<QWidget *>("transformFields");
|
|
QVERIFY(numbers.isEnabled());
|
|
session.beginTransform();
|
|
session.beginDistort();
|
|
QVERIFY(!numbers.isEnabled());
|
|
// The top right corner pulled out: the preview follows.
|
|
session.previewCorners({QPointF(40, 40), QPointF(80, 40), QPointF(60, 60), QPointF(40, 60)});
|
|
QVERIFY(canvas.synchronizeDisplay());
|
|
QImage shot = canvas.grab().toImage();
|
|
QCOMPARE(shot.pixelColor(215, 145), QColor(Qt::red));
|
|
QVERIFY(shot.pixelColor(225, 155) != QColor(Qt::red));
|
|
QCOMPARE(shot.pixelColor(197, 147), QColor(Qt::red));
|
|
// The same corners again change nothing.
|
|
QVERIFY(!canvas.synchronizeDisplay());
|
|
session.cancelTransform();
|
|
QVERIFY(canvas.synchronizeDisplay());
|
|
QVERIFY(numbers.isEnabled());
|
|
shot = canvas.grab().toImage();
|
|
QVERIFY(shot.pixelColor(215, 145) != QColor(Qt::red));
|
|
// An unlinked mask distorted alone shows in the layer's grid.
|
|
const QUuid red = session.activeLayerID().value();
|
|
QImage block(20, 20, QImage::Format_Grayscale8);
|
|
block.fill(0);
|
|
for (int y = 2; y < 18; ++y)
|
|
std::fill_n(block.scanLine(y) + 2, 8, uchar(255));
|
|
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
|
setMask(snapshot, red, LayerMask::assetFrom(block));
|
|
record(snapshot, red).maskLinked = false;
|
|
});
|
|
// Installing the snapshot fits the view: back to 100%.
|
|
session.zoom(1);
|
|
QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100));
|
|
session.selectTool(NavigationTool::move);
|
|
session.selectLayerTarget(red, true);
|
|
session.beginTransform();
|
|
session.beginDistort();
|
|
// The bottom left corner pulled out: the block leans left.
|
|
session.previewCorners({QPointF(40, 40), QPointF(60, 40), QPointF(60, 60), QPointF(30, 60)});
|
|
QVERIFY(session.transformEdit().value().mask);
|
|
const std::optional<QImage> preview = session.maskDistortPreview(layerWith(session, red));
|
|
QCOMPARE(int(preview.value().constScanLine(5)[5]), 255);
|
|
QCOMPARE(int(preview.value().constScanLine(16)[16]), 0);
|
|
canvas.synchronizeDisplay();
|
|
shot = canvas.grab().toImage();
|
|
QCOMPARE(shot.pixelColor(195, 145), QColor(Qt::red));
|
|
QVERIFY(shot.pixelColor(199, 154) != QColor(Qt::red));
|
|
}
|
|
|
|
void DistortCanvasTests::aDistortedLayerShowsThroughItsMask()
|
|
{
|
|
Shown shown;
|
|
CanvasView &canvas = *shown.canvas;
|
|
EditorSession &session = shown.session;
|
|
shown.settle();
|
|
session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"), QPointF(50, 50));
|
|
const QUuid red = session.activeLayerID().value();
|
|
// A linked mask hides the left half.
|
|
QImage half(20, 20, QImage::Format_Grayscale8);
|
|
half.fill(255);
|
|
for (int y = 0; y < 20; ++y)
|
|
std::fill_n(half.scanLine(y), 10, uchar(0));
|
|
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, LayerMask::assetFrom(half)); });
|
|
session.zoom(1);
|
|
session.selectTool(NavigationTool::move);
|
|
session.selectLayer(red);
|
|
session.beginTransform();
|
|
session.beginDistort();
|
|
session.previewCorners({QPointF(40, 40), QPointF(62, 40), QPointF(60, 60), QPointF(40, 60)});
|
|
canvas.synchronizeDisplay();
|
|
// The document sits at (150, 100); probes miss the handles.
|
|
const QImage shot = canvas.grab().toImage();
|
|
QVERIFY(shot.pixelColor(196, 154) != QColor(Qt::red));
|
|
QCOMPARE(shot.pixelColor(205, 146), QColor(Qt::red));
|
|
}
|
|
|
|
QTEST_MAIN(DistortCanvasTests)
|
|
#include "DistortCanvasTests.moc"
|