New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 15:58:57 -04:00
1 parent 765e071c0c
commit 0616f9b006
12 files changed
+420 -7

No files matched your search

+107
View File
@@ -3,6 +3,7 @@
#include <QClipboard>
#include <QImageReader>
#include <QImageWriter>
#include <QMenu>
#include <QtTest>
// The welcome sheet: sizes, its three ways on, the clipboard.
@@ -16,6 +17,31 @@ struct Sheet {
QLineEdit &height = *sheet.findChild<QLineEdit *>("heightInput");
QLabel &note = *sheet.findChild<QLabel *>("canvasNote");
QPushButton &create = *sheet.findChild<QPushButton *>("createCanvas");
QToolButton &presets = *sheet.findChild<QToolButton *>("presetSizes");
// The menu's entries, a separator as "-".
QStringList entries() const
{
QStringList titles;
for (const QAction *action : presets.menu()->actions())
titles << (action->isSeparator() ? QStringLiteral("-") : action->text());
return titles;
}
QString checked() const
{
QStringList titles;
for (const QAction *action : presets.menu()->actions())
if (action->isChecked())
titles << action->text();
return titles.join(',');
}
QAction &entry(const QString &title) const
{
for (QAction *action : presets.menu()->actions())
if (action->text() == title)
return *action;
throw std::runtime_error("no such preset");
}
};
QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone)
@@ -42,6 +68,9 @@ private slots:
void aCopiedPicturesSizeIsSuggestedOnce();
void theFirstTabSkipsTheClipboardOnce();
void clipboardSizesComeFromHeadersAlone();
void presetsAreSwiftsGroups();
void aPresetFillsTheFieldsAndTheMatchIsChecked();
void theMoreButtonDrawsThreeDotsAndOpensTheMenu();
};
void NewCanvasSheetTests::cleanup()
@@ -227,5 +256,83 @@ void NewCanvasSheetTests::clipboardSizesComeFromHeadersAlone()
QCOMPARE(NewCanvasSheet::clipboardDimensions(&turned), std::optional(QSize(50, 30)));
}
void NewCanvasSheetTests::presetsAreSwiftsGroups()
{
QStringList sizes;
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
for (const CanvasPreset &preset : group)
sizes << QStringLiteral("%1 %2x%3").arg(preset.title).arg(preset.width).arg(preset.height);
sizes << "-";
}
QCOMPARE(sizes, (QStringList{"4K 3840x2160", "1440p 2560x1440", "1080p 1920x1080", "-", "iPhone 18 Pro 1206x2622", "iPhone 18 Pro Max 1320x2868",
"MacBook Pro 14\" 3024x1964", "MacBook Pro 16\" 3456x2234", "Studio Display 5120x2880", "-", "Instagram Square 1080x1080",
"Instagram Portrait 1080x1350", "Instagram Story 1080x1920", "YouTube Thumb 1080x608", "-"}));
QCOMPARE(CanvasPreset::all.size(), size_t(12));
QCOMPARE(CanvasPreset::all.back().title, QString("YouTube Thumb"));
Sheet fixture;
QCOMPARE(fixture.entries(), (QStringList{"Custom", "-", "4K", "1440p", "1080p", "-", "iPhone 18 Pro", "iPhone 18 Pro Max", "MacBook Pro 14\"",
"MacBook Pro 16\"", "Studio Display", "-", "Instagram Square", "Instagram Portrait", "Instagram Story",
"YouTube Thumb"}));
QCOMPARE(fixture.presets.toolTip(), QString("Preset sizes for screens and common formats"));
QCOMPARE(fixture.presets.accessibleName(), QString("Preset sizes"));
QCOMPARE(fixture.presets.size(), QSize(28, 28));
QCOMPARE(fixture.presets.popupMode(), QToolButton::InstantPopup);
}
void NewCanvasSheetTests::aPresetFillsTheFieldsAndTheMatchIsChecked()
{
Sheet fixture;
QCOMPARE(fixture.checked(), QString("1080p"));
fixture.entry("Instagram Story").trigger();
QCOMPARE(fixture.width.text(), QString("1080"));
QCOMPARE(fixture.height.text(), QString("1920"));
QCOMPARE(fixture.checked(), QString("Instagram Story"));
fixture.entry("Studio Display").trigger();
QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("5120x2880"));
QCOMPARE(fixture.checked(), QString("Studio Display"));
// A size no preset has is Custom, matched as text.
fixture.height.setText("2881");
QCOMPARE(fixture.checked(), QString("Custom"));
fixture.height.setText("2880");
QCOMPARE(fixture.checked(), QString("Studio Display"));
fixture.width.setText("05120");
QCOMPARE(fixture.checked(), QString("Custom"));
fixture.width.setText("1080");
fixture.height.setText("608");
QCOMPARE(fixture.checked(), QString("YouTube Thumb"));
// Custom fills nothing in.
fixture.entry("Custom").trigger();
QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("1080x608"));
QCOMPARE(fixture.checked(), QString("YouTube Thumb"));
fixture.entry("4K").trigger();
QCOMPARE(fixture.checked(), QString("4K"));
QVERIFY(fixture.create.isEnabled());
QTest::mouseClick(&fixture.create, Qt::LeftButton);
QCOMPARE(fixture.created, (QList<QSize>{QSize(3840, 2160)}));
}
void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu()
{
Sheet fixture;
fixture.sheet.show();
QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet));
// Flush with the content's right edge, 28 points in.
QCOMPARE(fixture.presets.geometry().right() + 1, 500 - 28);
const QImage image = fixture.presets.grab().toImage();
const QColor ground = image.pixelColor(2, 2), ink = fixture.presets.palette().color(QPalette::WindowText);
// Dots centred at x 26.75, y 9, 14 and 19.
for (const int y : {9, 14, 19})
QVERIFY2(std::abs(qGray(image.pixel(26, y)) - qGray(ink.rgb())) < std::abs(qGray(image.pixel(26, y)) - qGray(ground.rgb())), qPrintable(QString::number(y)));
for (const QPoint clear : {QPoint(26, 11), QPoint(26, 16), QPoint(26, 5), QPoint(26, 23), QPoint(22, 14), QPoint(14, 14)})
QCOMPARE(image.pixelColor(clear), ground);
bool shown = false;
QTimer::singleShot(0, fixture.presets.menu(), [&] {
shown = fixture.presets.menu()->isVisible();
fixture.presets.menu()->close();
});
QTest::mouseClick(&fixture.presets, Qt::LeftButton);
QVERIFY(shown);
}
QTEST_MAIN(NewCanvasSheetTests)
#include "NewCanvasSheetTests.moc"
+4
View File
@@ -119,6 +119,10 @@ void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap()
ProjectStore::save(twoLayers(), path);
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"}));
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"});
// The preview and its folder, before the project's folder.
faults.trace.clear();
ProjectStore::save(twoLayers(), path, QuickLookImages{"preview"});
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync file", "sync folder", "sync folder", "exchange", "sync folder"}));
}
void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data()
+144
View File
@@ -0,0 +1,144 @@
#include "DialogDesk.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectController.h"
#include "ProjectFixtures.h"
#include <QBuffer>
#include <QColorSpace>
#include <QImageReader>
#include <QImageWriter>
#include <QTemporaryDir>
#include <QtTest>
// The project's QuickLook/Preview.jpg: written on save, ignored on load.
namespace {
const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden;
// One layer stretched over a canvas of this size.
ProjectSnapshot stretched(int width, int height, const QImage &pixels)
{
const QUuid id = QUuid::createUuid();
const ProjectLayerRecord layer{.id = id, .name = "Stretched", .isVisible = true,
.transform = {.origin = {0, 0}, .size = {double(width), double(height)}}, .imageFile = uuidString(id) + ".png"};
return {.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = id, .layers = {layer}},
.images = {{id, asset(pixels, "source")}}};
}
QImage decoded(const QByteArray &data)
{
QImage image = QImage::fromData(data, "jpeg");
if (image.isNull())
throw std::runtime_error("the preview is no JPEG");
return image;
}
QSize previewSize(int width, int height)
{
QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied);
empty.fill(0);
return decoded(ImageExporter::quickLookImages(stretched(width, height, empty)).value().preview).size();
}
}
class QuickLookPreviewTests : public QObject {
Q_OBJECT
private slots:
void theStoreWritesThePreviewAndLoadingIgnoresIt();
void thePreviewIsTheImageOnWhiteAtQualityEighty();
void theLongSideIsAt1024AndNothingGrows();
void pastFiftyMegapixelsThereIsNone();
void aCanvasThatCannotRenderSavesWithoutOne();
void everySaveWritesIt();
};
void QuickLookPreviewTests::theStoreWritesThePreviewAndLoadingIgnoresIt()
{
QTemporaryDir root;
const QString path = root.filePath("Previewed.comp");
const ProjectSnapshot snapshot = twoLayers();
ProjectStore::save(snapshot, path, QuickLookImages{"not even a JPEG"});
QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json", "QuickLook"}));
QCOMPARE(QDir(path + "/QuickLook").entryList(everything), QStringList{"Preview.jpg"});
QCOMPARE(contents(path + "/QuickLook/Preview.jpg"), QByteArray("not even a JPEG"));
// Its bytes are never read.
QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded());
// Saved without one, the project has none.
ProjectStore::save(snapshot, path);
QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json"}));
QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded());
}
void QuickLookPreviewTests::thePreviewIsTheImageOnWhiteAtQualityEighty()
{
// Half opaque red, half clear, over 2048 by 1024.
const ProjectSnapshot snapshot = stretched(2048, 1024, halfRed());
const QByteArray preview = ImageExporter::quickLookImages(snapshot).value().preview;
const QImage image = decoded(preview);
QCOMPARE(image.size(), QSize(1024, 512));
QVERIFY(qAbs(image.pixelColor(200, 256).red() - 255) <= 3 && image.pixelColor(200, 256).green() <= 3);
QCOMPARE(image.pixelColor(800, 256), QColor(Qt::white));
// Byte for byte: smooth scaling, white, quality 80, 72 dpi.
const QImage scaled = ImageExporter::render(snapshot).image.scaled(1024, 512, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QImage flattened(1024, 512, QImage::Format_RGB888);
flattened.fill(Qt::white);
QPainter(&flattened).drawImage(QRectF(flattened.rect()), scaled);
flattened.setDotsPerMeterX(2835);
flattened.setDotsPerMeterY(2835);
flattened.setColorSpace(QColorSpace::SRgb);
QByteArray expected;
QBuffer buffer(&expected);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(80);
QVERIFY(writer.write(flattened));
QCOMPARE(preview, expected);
}
void QuickLookPreviewTests::theLongSideIsAt1024AndNothingGrows()
{
QCOMPARE(previewSize(300, 200), QSize(300, 200));
QCOMPARE(previewSize(1024, 7), QSize(1024, 7));
QCOMPARE(previewSize(1025, 1025), QSize(1024, 1024));
// 1001 × 1024 / 3000 is 341.67: rounded, not cut.
QCOMPARE(previewSize(3000, 1001), QSize(1024, 342));
QCOMPARE(previewSize(10, 5000), QSize(2, 1024));
// A sliver keeps a pixel.
QCOMPARE(previewSize(1, 3000), QSize(1, 1024));
QCOMPARE(previewSize(3000, 1), QSize(1024, 1));
}
void QuickLookPreviewTests::pastFiftyMegapixelsThereIsNone()
{
QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied);
empty.fill(0);
QVERIFY(!ImageExporter::quickLookImages(stretched(7072, 7071, empty)));
QCOMPARE(decoded(ImageExporter::quickLookImages(stretched(10'000, 5'000, empty)).value().preview).size(), QSize(1024, 512));
}
void QuickLookPreviewTests::aCanvasThatCannotRenderSavesWithoutOne()
{
ProjectSnapshot snapshot = twoLayers();
snapshot.images.clear();
QTest::ignoreMessage(QtWarningMsg, "no Quick Look preview: An image inside the project is missing or damaged. The current document has not been replaced.");
QVERIFY(!ImageExporter::quickLookImages(snapshot));
}
void QuickLookPreviewTests::everySaveWritesIt()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(8, 6);
QImage image(8, 6, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::blue);
session.insert(ImportedImage(image, QImage(), "Blue"));
ProjectController controller(session);
DialogDesk desk;
desk.replies = {folder.filePath("Saved")};
bool saved = false;
controller.save(false, [&](bool done) { saved = done; });
QTRY_VERIFY(saved);
const QImage preview = decoded(contents(folder.filePath("Saved.comp/QuickLook/Preview.jpg")));
QCOMPARE(preview.size(), QSize(8, 6));
QVERIFY(preview.pixelColor(4, 3).blue() >= 250 && preview.pixelColor(4, 3).red() <= 5);
}
QTEST_MAIN(QuickLookPreviewTests)
#include "QuickLookPreviewTests.moc"
+1
View File
@@ -203,6 +203,7 @@ compositor_test(KeyboardZoomTests)
add_dependencies(ProjectWorkspaceViewTests omaphoto)
compositor_test(ProjectSessionTests)
compositor_test(ProjectTests)
compositor_test(QuickLookPreviewTests)
compositor_test(RasterSnapshotSessionTests)
compositor_test(RasterSnapshotTests)
compositor_test(SessionGateTests)