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

+33
View File
@@ -45,6 +45,24 @@ QByteArray encode(QImage image, const char *format, int quality, double resoluti
throw ExportError(ExportError::Kind::encode);
return data;
}
QByteArray scaledJPEG(const QImage &image, double longSide)
try {
const double scale = std::min(1.0, longSide / std::max(image.width(), image.height()));
const QSize size(std::max(1, int(std::round(image.width() * scale))), std::max(1, int(std::round(image.height() * scale))));
const QImage scaled = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QImage flattened(size, QImage::Format_RGB888);
if (scaled.isNull() || flattened.isNull())
throw ExportError(ExportError::Kind::render);
flattened.fill(Qt::white);
{
QPainter painter(&flattened);
painter.drawImage(QRectF(flattened.rect()), scaled);
}
return encode(flattened, "jpeg", 80, 72);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {}
@@ -118,6 +136,21 @@ try {
throw ExportError(ExportError::Kind::render);
}
std::optional<QuickLookImages> ImageExporter::quickLookImages(const ProjectSnapshot &snapshot)
{
if (snapshot.manifest.width * snapshot.manifest.height > 50'000'000) {
qCInfo(lcIO) << "no Quick Look preview for" << snapshot.manifest.width << "x" << snapshot.manifest.height;
return std::nullopt;
}
try {
return QuickLookImages{scaledJPEG(render(snapshot).image, 1024)};
} catch (const std::runtime_error &error) {
// Swift's try? saves without one; the log says why.
qCWarning(lcIO) << "no Quick Look preview:" << error.what();
return std::nullopt;
}
}
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
{
const ExportRaster raster = render(snapshot);
+3
View File
@@ -3,6 +3,7 @@
#include <QByteArray>
#include <QImage>
#include <functional>
#include <optional>
#include <stdexcept>
class ExportError : public std::runtime_error {
@@ -39,6 +40,8 @@ struct JPEGResult {
namespace ImageExporter {
ExportRaster render(const ProjectSnapshot &snapshot);
// Flattened on white, 1024 a side; none past 50 megapixels.
std::optional<QuickLookImages> quickLookImages(const ProjectSnapshot &snapshot);
QByteArray pngData(const ProjectSnapshot &snapshot);
// Stops at Swift's three points once `cancelled` says so.
JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {});
+2 -1
View File
@@ -1,6 +1,7 @@
#include "IO/ProjectController.h"
#include "IO/RecentProjects.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QFileDialog>
@@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::function<void(bool)
// The store blocks on the disk: off the UI thread.
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
try {
ProjectStore::save(snapshot, destination);
ProjectStore::save(snapshot, destination, ImageExporter::quickLookImages(snapshot));
return std::nullopt;
} catch (const std::runtime_error &error) {
return QString::fromUtf8(error.what());
+8 -1
View File
@@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QStri
{
}
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook)
{
validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files;
@@ -323,6 +323,13 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
for (const auto &[filename, data] : files)
writeFile(staged + QLatin1String("/images/") + filename, data);
syncDirectory(staged + QLatin1String("/images"));
// Quick Look's Space-bar preview; loading ignores it.
if (quickLook) {
if (!QDir().mkdir(staged + QLatin1String("/QuickLook")))
fail(QStringLiteral("could not create"), staged + QLatin1String("/QuickLook"), QStringLiteral("the folder cannot be made"));
writeFile(staged + QLatin1String("/QuickLook/Preview.jpg"), quickLook->preview);
syncDirectory(staged + QLatin1String("/QuickLook"));
}
syncDirectory(staged);
replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep);
qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images";
+6 -1
View File
@@ -97,9 +97,14 @@ private:
ProjectError(Kind kind, std::optional<qint64> version, const QString &description);
};
// Swift's, in ImageExporter.swift; here, as the exporter includes the store.
struct QuickLookImages {
QByteArray preview;
};
// A .comp project: a directory holding manifest.json and images/.
namespace ProjectStore {
// Replaces the project in one step or leaves it untouched.
void save(const ProjectSnapshot &snapshot, const QString &path);
void save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook = std::nullopt);
ProjectSnapshot load(const QString &path);
}
+95 -4
View File
@@ -5,9 +5,13 @@
#include <QBuffer>
#include <QClipboard>
#include <QGridLayout>
#include <QActionGroup>
#include <QImageReader>
#include <QMenu>
#include <QPainter>
#include <QShortcut>
#include <QVBoxLayout>
#include <algorithm>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
@@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C
label->setForegroundRole(role);
return label;
}
// Swift's More button: three dots drawn exactly, flush right.
class MoreButton : public QToolButton {
public:
using QToolButton::QToolButton;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::WindowText));
const double dot = 2.5, top = (height() - 5 * dot) / 2;
for (int index = 0; index < 3; ++index)
painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot));
}
};
}
const std::vector<std::vector<CanvasPreset>> CanvasPreset::groups = {
{
{QStringLiteral("4K"), 3840, 2160},
{QStringLiteral("1440p"), 2560, 1440},
{QStringLiteral("1080p"), 1920, 1080},
},
{
{QStringLiteral("iPhone 18 Pro"), 1206, 2622},
{QStringLiteral("iPhone 18 Pro Max"), 1320, 2868},
{QStringLiteral("MacBook Pro 14\""), 3024, 1964},
{QStringLiteral("MacBook Pro 16\""), 3456, 2234},
{QStringLiteral("Studio Display"), 5120, 2880},
},
{
{QStringLiteral("Instagram Square"), 1080, 1080},
{QStringLiteral("Instagram Portrait"), 1080, 1350},
{QStringLiteral("Instagram Story"), 1080, 1920},
{QStringLiteral("YouTube Thumb"), 1080, 608},
},
};
const std::vector<CanvasPreset> CanvasPreset::all = [] {
std::vector<CanvasPreset> presets;
for (const std::vector<CanvasPreset> &group : groups)
presets.insert(presets.end(), group.begin(), group.end());
return presets;
}();
NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent)
: QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)),
m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_create(new QPushButton(QStringLiteral("Create canvas"), this))
m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this))
{
// Swift's fields stretch the sheet to its full 500 points.
setFixedWidth(500);
@@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
auto *column = new QVBoxLayout(this);
column->setContentsMargins(28, 28, 28, 28);
column->setSpacing(24);
auto *heading = new QVBoxLayout;
heading->setSpacing(6);
auto *heading = new QHBoxLayout;
heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this));
heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this));
heading->addStretch(1);
heading->addWidget(m_presets);
column->addLayout(heading);
column->addLayout(fields);
column->addWidget(m_note);
column->addLayout(buttons);
m_presets->setObjectName(QStringLiteral("presetSizes"));
m_presets->setFixedSize(28, 28);
m_presets->setPopupMode(QToolButton::InstantPopup);
m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats"));
m_presets->setAccessibleName(QStringLiteral("Preset sizes"));
auto *menu = new QMenu(m_presets);
auto *choices = new QActionGroup(menu);
// Custom changes nothing; the match stays checked.
QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom")));
custom->setCheckable(true);
custom->setData(-1);
connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset);
int index = 0;
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
menu->addSeparator();
for (const CanvasPreset &preset : group) {
QAction *action = choices->addAction(menu->addAction(preset.title));
action->setCheckable(true);
action->setData(index++);
connect(action, &QAction::triggered, this, [this, preset] {
m_width->setText(QString::number(preset.width));
m_height->setText(QString::number(preset.height));
});
}
}
m_presets->setMenu(menu);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
// Return anywhere on the sheet is the default action.
std::vector<QShortcut *> returns;
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
@@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
synchronize();
validate();
checkPreset();
}
void NewCanvasSheet::checkPreset()
{
const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) {
return QString::number(preset.width) == m_width->text() && QString::number(preset.height) == m_height->text();
});
const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin()));
for (QAction *action : m_presets->menu()->actions())
if (action->data() == index)
action->setChecked(true);
}
void NewCanvasSheet::synchronize()
+15
View File
@@ -4,8 +4,20 @@
#include <QLineEdit>
#include <QMimeData>
#include <QPushButton>
#include <QToolButton>
#include <QWidget>
#include <functional>
#include <vector>
// New Canvas sizes in pixels, upright as held.
struct CanvasPreset {
QString title;
int width;
int height;
// Resolutions, Apple screens, then social formats; the menu divides them.
static const std::vector<std::vector<CanvasPreset>> groups;
static const std::vector<CanvasPreset> all;
};
// The welcome: a new canvas, a project or an image.
class NewCanvasSheet : public QWidget {
@@ -23,6 +35,8 @@ private:
void synchronize();
void validate();
void create();
// Checks the preset the fields match, else Custom.
void checkPreset();
EditorSession &m_session;
const std::function<void(int, int)> m_onCreate;
@@ -30,5 +44,6 @@ private:
QLineEdit *const m_height;
QLabel *const m_note;
QPushButton *const m_create;
QToolButton *const m_presets;
bool m_suggestedClipboardSize = false;
};