14.20: the Navigator minimap from 300%

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 11:22:15 -04:00
1 parent bd4602fed9
commit 085b4dac6e
17 files changed
+733 -1

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
#include "Document/EditorSession.h"
#include "Rendering/NavigatorGeometry.h"
#include "SessionFixtures.h"
#include "UI/NavigatorMinimap.h"
#include <QSettings>
#include <QStandardPaths>
#include <QtTest>
// Swift 1.4.9's Navigator: the geometry, the picture, the minimap.
class NavigatorTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void documentFitsTheBoxCentered();
void visibleRectFollowsZoomAndPan();
void centeringKeepsZoomAndStopsFollowingFit();
void pictureFitsTheLongerSideAndShowsTheComposite();
void smallDocumentsAreNotEnlarged();
void minimapFitsTheDocument();
void hugeDocumentsWithAdjustmentsStillShow();
void blursShrinkWithThePicture();
void selectionChangesDoNotRedrawThePicture();
void placedMasksMotionAndEachAxisScale();
void editsInProgressStayOut();
void theMinimapShowsFrom300PercentWhenTurnedOn();
void thePictureRedrawsOnceEditsSettle();
void aClickOrDragMovesTheView();
void theViewsBoxIsDrawnInTheAccent();
};
namespace {
ImportedImage red(int width, int height)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::red);
return ImportedImage(image, image, QStringLiteral("Red"));
}
// A 400 by 300 document, viewed 800 by 600.
CanvasViewport viewport()
{
CanvasViewport viewport;
viewport.resize(QSizeF(800, 600), 1, QSizeF(400, 300));
return viewport;
}
QColor at(const QImage &image, int x, int y)
{
return image.pixelColor(x, y);
}
// A red layer of `size` at the origin.
QUuid placeRed(EditorSession &session, QSize pixels, QSizeF size)
{
session.insert(red(pixels.width(), pixels.height()));
const QUuid id = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).transform.origin = QPointF(0, 0);
record(snapshot, id).transform.size = size;
});
session.selectLayer(id);
return id;
}
struct Shown {
EditorSession session;
NavigatorMinimap minimap{session};
Shown()
{
NavigatorMinimap::setVisibleSetting(true);
session.createDocument(400, 300);
placeRed(session, QSize(400, 300), QSizeF(400, 300));
session.viewport.resize(QSizeF(800, 600), 1, QSizeF(400, 300));
session.zoom(4, session.viewport.center());
}
};
}
void NavigatorTests::initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
QSettings().clear();
}
void NavigatorTests::documentFitsTheBoxCentered()
{
const NavigatorGeometry geometry{QSizeF(400, 200), QSizeF(200, 200)};
QCOMPARE(geometry.imageRect(), QRectF(0, 50, 200, 100));
QCOMPARE(geometry.thumbnailRect(QRectF(100, 50, 200, 100)), QRectF(50, 75, 100, 50));
QCOMPARE(geometry.documentPoint(QPointF(100, 100)), QPointF(200, 100));
// Points outside the picture land on the document's edge.
QCOMPARE(geometry.documentPoint(QPointF(-20, 199)), QPointF(0, 200));
QCOMPARE((NavigatorGeometry{QSizeF(0, 0), QSizeF(10, 10)}.imageRect()), QRectF());
QCOMPARE((NavigatorGeometry{QSizeF(0, 0), QSizeF(10, 10)}.documentPoint(QPointF(5, 5))), QPointF());
QCOMPARE((NavigatorGeometry{QSizeF(10, 10), QSizeF(0, 10)}.imageRect()), QRectF());
QCOMPARE((NavigatorGeometry{QSizeF(10, 0), QSizeF(10, 10)}.imageRect()), QRectF());
QCOMPARE((NavigatorGeometry{QSizeF(0, 10), QSizeF(10, 10)}.thumbnailRect(QRectF(1, 1, 2, 2))), QRectF());
QCOMPARE((NavigatorGeometry{QSizeF(0, 10), QSizeF(10, 10)}.documentPoint(QPointF(5, 5))), QPointF());
}
void NavigatorTests::visibleRectFollowsZoomAndPan()
{
const QSizeF size(400, 300);
CanvasViewport view = viewport();
view.setZoom(4, view.center(), size);
const QRectF visible = view.visibleDocumentRect(size);
QVERIFY(std::abs(visible.width() - 200) < 0.001 && std::abs(visible.height() - 150) < 0.001);
QVERIFY(std::abs(visible.center().x() - 200) < 0.001 && std::abs(visible.center().y() - 150) < 0.001);
// Panned by 40 points: 10 pixels across at 4×.
view.translate(QSizeF(40, -80));
QVERIFY(std::abs(view.visibleDocumentRect(size).center().x() - 190) < 0.001 && std::abs(view.visibleDocumentRect(size).center().y() - 170) < 0.001);
}
void NavigatorTests::centeringKeepsZoomAndStopsFollowingFit()
{
const QSizeF size(400, 300);
CanvasViewport view = viewport();
const double zoom = view.zoom();
view.centerView(QPointF(50, 60), size);
const QRectF visible = view.visibleDocumentRect(size);
QVERIFY(std::abs(visible.center().x() - 50) < 0.001 && std::abs(visible.center().y() - 60) < 0.001);
QCOMPARE(view.zoom(), zoom);
QVERIFY(!view.followsFit());
// A later window resize keeps the view where it was.
view.resize(QSizeF(1000, 700), 1, size);
QCOMPARE(view.zoom(), zoom);
}
void NavigatorTests::pictureFitsTheLongerSideAndShowsTheComposite()
{
EditorSession session;
QVERIFY(!session.navigatorImage(100));
session.createDocument(400, 200);
QVERIFY(!session.navigatorImage(0));
placeRed(session, QSize(200, 200), QSizeF(200, 200));
const QImage picture = session.navigatorImage(100).value();
QCOMPARE(picture.size(), QSize(100, 50));
QCOMPARE(at(picture, 25, 25), QColor(255, 0, 0));
QCOMPARE(at(picture, 75, 25).alpha(), 0);
// Adjustment layers apply, as on the canvas.
session.addAdjustment(AdjustmentKind::invert);
QCOMPARE(at(session.navigatorImage(100).value(), 25, 25), QColor(0, 255, 255));
}
void NavigatorTests::smallDocumentsAreNotEnlarged()
{
EditorSession session;
session.createDocument(40, 30);
QCOMPARE(session.navigatorImage(900).value().size(), QSize(40, 30));
// Each side rounded, a pixel at least.
session.createDocument(1000, 3);
QCOMPARE(session.navigatorImage(100).value().size(), QSize(100, 1));
session.createDocument(1000, 15);
QCOMPARE(session.navigatorImage(100).value().size(), QSize(100, 2));
}
void NavigatorTests::minimapFitsTheDocument()
{
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(4000, 3000)), QSizeF(120, 90));
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(6000, 2000)), QSizeF(126, 42));
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(1000, 999)), QSizeF(90, 90));
// 125.7 rounds up.
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(1000, 716)), QSizeF(126, 90));
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(0, 0)), QSizeF(0, 0));
}
// Past one surface, drawn at the picture's size, it shows.
void NavigatorTests::hugeDocumentsWithAdjustmentsStillShow()
{
EditorSession session;
session.createDocument(20'000, 12'000);
placeRed(session, QSize(200, 120), QSizeF(20'000, 12'000));
session.addAdjustment(AdjustmentKind::invert);
const QImage picture = session.navigatorImage(100).value();
QCOMPARE(picture.size(), QSize(100, 60));
QCOMPARE(at(picture, 50, 30), QColor(0, 255, 255));
}
void NavigatorTests::blursShrinkWithThePicture()
{
EditorSession session;
session.createDocument(2000, 2000);
placeRed(session, QSize(100, 200), QSizeF(1000, 2000));
session.addAdjustment(AdjustmentKind::gaussianBlur);
session.setAdjustmentEditingID(std::nullopt);
const QUuid blur = session.activeLayerID().value();
LayerAdjustment adjustment = layerWith(session, blur).adjustment.value();
adjustment.blurRadius = 100;
session.updateAdjustment(blur, adjustment);
const QImage picture = session.navigatorImage(100).value();
// Five radii from every edge at the picture's scale: solid.
QVERIFY2(at(picture, 25, 50).alpha() >= 250, qPrintable(QString::number(at(picture, 25, 50).alpha())));
// Unshrunk, a radius of 100 would reach the edge here.
QVERIFY(at(picture, 49, 50).alpha() > 0 && at(picture, 49, 50).alpha() < 250);
}
void NavigatorTests::selectionChangesDoNotRedrawThePicture()
{
EditorSession session;
session.createDocument(100, 100);
placeRed(session, QSize(10, 10), QSizeF(10, 10));
const NavigatorMinimap::RenderKey key(session.document(), 0);
session.selectAll();
QVERIFY(NavigatorMinimap::RenderKey(session.document(), 0) == key);
QVERIFY(!(NavigatorMinimap::RenderKey(session.document(), 1) == key));
std::optional<CanvasDocument> hidden = session.document();
hidden->layers.back().isVisible = false;
QVERIFY(!(NavigatorMinimap::RenderKey(hidden, 0) == key));
std::optional<CanvasDocument> other = session.document();
other->id = QUuid::createUuid();
QVERIFY(!(NavigatorMinimap::RenderKey(other, 0) == key));
}
void NavigatorTests::placedMasksMotionAndEachAxisScale()
{
// A placed mask shrinks with its layer: white, then black.
EditorSession session;
session.createDocument(400, 200);
const QUuid id = placeRed(session, QSize(300, 200), QSizeF(300, 200));
QImage halves(4, 1, QImage::Format_Grayscale8);
const uchar values[] = {255, 255, 0, 0};
std::copy_n(values, 4, halves.scanLine(0));
rewrite(session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, LayerMask::assetFrom(halves));
record(snapshot, id).maskPlacement = LayerTransform{.origin = QPointF(0, 0), .size = QSizeF(200, 200)};
});
const QImage masked = session.navigatorImage(100).value();
// At a quarter: white to 25, black to 50.
QVERIFY(at(masked, 15, 25).alpha() == 255 && at(masked, 40, 25).alpha() == 0);
// Each side alone: 1000 by 15 is 100 by 2.
session.createDocument(1000, 15);
placeRed(session, QSize(1000, 15), QSizeF(1000, 15));
// The halving softens the last row; half would show half.
const int bottom = at(session.navigatorImage(100).value(), 50, 1).alpha();
QVERIFY2(bottom > 200, qPrintable(QString::number(bottom)));
// A streak measured in document pixels shrinks too.
session.createDocument(2000, 2000);
placeRed(session, QSize(100, 200), QSizeF(1000, 2000));
session.addAdjustment(AdjustmentKind::motionBlur);
session.setAdjustmentEditingID(std::nullopt);
const QUuid streak = session.activeLayerID().value();
LayerAdjustment adjustment = layerWith(session, streak).adjustment.value();
adjustment.motionAngle = 0;
adjustment.motionDistance = 100;
session.updateAdjustment(streak, adjustment);
const QImage picture = session.navigatorImage(100).value();
QVERIFY2(at(picture, 25, 50).alpha() >= 250, qPrintable(QString::number(at(picture, 25, 50).alpha())));
}
// Swift draws a bare session: a pending transform stays out.
void NavigatorTests::editsInProgressStayOut()
{
EditorSession session;
session.createDocument(400, 200);
placeRed(session, QSize(200, 200), QSizeF(200, 200));
session.selectTool(NavigationTool::move);
session.beginTransform();
LayerTransform moved = session.transformEdit().value().draft;
moved.origin = QPointF(200, 0);
session.previewTransform(moved);
const QImage picture = session.navigatorImage(100).value();
QVERIFY(at(picture, 25, 25).alpha() == 255 && at(picture, 75, 25).alpha() == 0);
}
void NavigatorTests::theMinimapShowsFrom300PercentWhenTurnedOn()
{
NavigatorMinimap::setVisibleSetting(false);
QCOMPARE(QSettings().value(QLatin1String(NavigatorMinimap::visibleKey)).toBool(), false);
Shown shown;
QVERIFY(!shown.minimap.isHidden() && shown.minimap.size() == QSize(120, 90));
QCOMPARE(shown.minimap.picture().size(), QSize(300, 225));
QCOMPARE(shown.minimap.accessibleName(), QString("Navigator"));
QCOMPARE(shown.minimap.accessibleDescription(), QString("Click or drag to move the view"));
shown.session.zoom(2.9, shown.session.viewport.center());
QVERIFY(shown.minimap.isHidden() && shown.minimap.picture().isNull());
shown.session.zoom(3, shown.session.viewport.center());
QVERIFY(!shown.minimap.isHidden() && !shown.minimap.picture().isNull());
shown.session.setCanvasOnly(true);
QVERIFY(shown.minimap.isHidden());
shown.session.setCanvasOnly(false);
NavigatorMinimap::setVisibleSetting(false);
shown.session.notify();
QVERIFY(shown.minimap.isHidden());
NavigatorMinimap::setVisibleSetting(true);
shown.session.notify();
QVERIFY(!shown.minimap.isHidden());
shown.session.clearProject();
QVERIFY(shown.minimap.isHidden());
}
void NavigatorTests::thePictureRedrawsOnceEditsSettle()
{
Shown shown;
QTimer &wait = *shown.minimap.findChild<QTimer *>(QStringLiteral("navigatorWait"));
QVERIFY(wait.interval() == 250 && wait.isSingleShot() && wait.timerType() == Qt::PreciseTimer && !wait.isActive());
const QImage first = shown.minimap.picture();
QCOMPARE(at(first, 150, 100), QColor(255, 0, 0));
// A selection redraws nothing.
shown.session.selectAll();
QVERIFY(!wait.isActive());
// An edit waits a moment, then redraws once.
shown.session.addAdjustment(AdjustmentKind::invert);
QVERIFY(wait.isActive());
QCOMPARE(shown.minimap.picture().cacheKey(), first.cacheKey());
QTRY_COMPARE(at(shown.minimap.picture(), 150, 100), QColor(0, 255, 255));
QVERIFY(!wait.isActive());
// Hidden mid-wait, the wait goes with the picture.
shown.session.setLayerOpacity(0.5);
QVERIFY(wait.isActive());
shown.session.zoom(2, shown.session.viewport.center());
QVERIFY(!wait.isActive() && shown.minimap.picture().isNull());
}
void NavigatorTests::aClickOrDragMovesTheView()
{
Shown shown;
const QSizeF size(400, 300);
QSignalSpy changes(&shown.session, &EditorSession::changed);
QTest::mousePress(&shown.minimap, Qt::LeftButton, Qt::NoModifier, QPoint(30, 30));
QCOMPARE(changes.count(), 1);
QRectF visible = shown.session.viewport.visibleDocumentRect(size);
QVERIFY(std::abs(visible.center().x() - 100) < 0.001 && std::abs(visible.center().y() - 100) < 0.001);
QCOMPARE(shown.session.viewport.zoom(), 4.0);
// A drag past the picture holds to the document's edge.
QTest::mouseMove(&shown.minimap, QPoint(119, 89));
QMouseEvent move(QEvent::MouseMove, QPointF(200, 200), QPointF(200, 200), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&shown.minimap, &move);
visible = shown.session.viewport.visibleDocumentRect(size);
QVERIFY(std::abs(visible.center().x() - 400) < 0.001 && std::abs(visible.center().y() - 300) < 0.001);
// Without the left button nothing moves.
QMouseEvent hover(QEvent::MouseMove, QPointF(60, 45), QPointF(60, 45), Qt::NoButton, Qt::RightButton, Qt::NoModifier);
QApplication::sendEvent(&shown.minimap, &hover);
QTest::mousePress(&shown.minimap, Qt::RightButton, Qt::NoModifier, QPoint(60, 45));
QVERIFY(std::abs(shown.session.viewport.visibleDocumentRect(size).center().x() - 400) < 0.001);
// Over the canvas's cross, the arrow.
QWidget canvas;
canvas.setCursor(Qt::CrossCursor);
NavigatorMinimap inside(shown.session, &canvas);
QCOMPARE(inside.cursor().shape(), Qt::ArrowCursor);
}
void NavigatorTests::theViewsBoxIsDrawnInTheAccent()
{
Shown shown;
QPalette palette = shown.minimap.palette();
palette.setColor(QPalette::Active, QPalette::Highlight, QColor(0, 200, 0));
shown.minimap.setPalette(palette);
const QImage drawn = shown.minimap.grab().toImage();
// The view's middle 200 × 150 pixels: 30 to 90.
QCOMPARE(at(drawn, 30, 45), QColor(0, 200, 0));
QCOMPARE(at(drawn, 31, 45), QColor(0, 200, 0));
QCOMPARE(at(drawn, 89, 45), QColor(0, 200, 0));
QCOMPARE(at(drawn, 60, 23), QColor(0, 200, 0));
QCOMPARE(at(drawn, 60, 45), QColor(255, 0, 0));
QCOMPARE(at(drawn, 28, 45), QColor(255, 0, 0));
// The rim: white at 25% over red at the edge.
QCOMPARE(at(drawn, 0, 45), QColor(255, 64, 64));
// Clear pixels show the minimap's own gray 0.11.
rewrite(shown.session, [](ProjectSnapshot &snapshot) { snapshot.manifest.layers.back().transform.size = QSizeF(200, 300); });
shown.session.zoom(4, shown.session.viewport.center());
QTRY_COMPARE(at(shown.minimap.grab().toImage(), 110, 80), QColor(28, 28, 28));
// A view smaller than three points still shows three.
shown.session.viewport.resize(QSizeF(40, 30), 1, QSizeF(400, 300));
shown.session.zoom(32, shown.session.viewport.center());
const QRectF view = shown.session.viewport.visibleDocumentRect(QSizeF(400, 300));
QVERIFY(view.width() * 0.3 < 1);
const int left = int(std::floor(view.left() * 0.3)), row = int(view.center().y() * 0.3);
const QImage tiny = shown.minimap.grab().toImage();
QVERIFY(at(tiny, left + 1, row).green() > 100 && at(tiny, left + 1, row).red() < 60);
QCOMPARE(at(tiny, left - 2, row), QColor(255, 0, 0));
}
QTEST_MAIN(NavigatorTests)
#include "NavigatorTests.moc"