Export JPEG: a zoomable full-size preview, its buttons and the View menu's zoom keys (13.4b)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 08:53:43 -04:00
1 parent bc49b722eb
commit 64ebd1cdf9
17 files changed
+702 -74

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
#include "UI/JPEGExportSheet.h"
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <algorithm>
#include <cmath>
double JPEGPreview::fitZoom(int width, int height, QSizeF frame, double displayScale)
{
const double scale = std::max(1.0, displayScale);
return std::min(frame.width() / (width / scale), frame.height() / (height / scale));
}
std::optional<double> JPEGPreview::step(double zoom, int direction)
{
if (direction > 0) {
const auto next = std::find_if(steps.begin(), steps.end(), [zoom](double step) { return step > zoom * 1.001; });
return next == steps.end() ? std::nullopt : std::optional(*next);
}
const auto next = std::find_if(steps.rbegin(), steps.rend(), [zoom](double step) { return step < zoom * 0.999; });
return next == steps.rend() ? std::nullopt : std::optional(*next);
}
JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent)
: QAbstractScrollArea(parent), m_pixels(pixels), m_zoomChanged(std::move(zoomChanged))
{
setFixedSize(frame);
setFrameShape(QFrame::NoFrame);
setToolTip(QStringLiteral("Drag or scroll to move around; double-click switches between Fit and 100%"));
horizontalScrollBar()->setSingleStep(20);
verticalScrollBar()->setSingleStep(20);
}
void JPEGPreview::setImage(const QImage &image, bool updating)
{
// A new image only: the averaged copy still holds.
if (image.cacheKey() != m_image.cacheKey())
m_reduced = QImage();
m_image = image;
m_updating = updating;
viewport()->update();
}
double JPEGPreview::shownZoom() const
{
return m_zoom.value_or(fitZoom(m_pixels.width(), m_pixels.height(), QSizeF(frame), devicePixelRatioF()));
}
void JPEGPreview::zoomBy(int direction)
{
if (const std::optional<double> next = step(shownZoom(), direction))
setZoom(next);
}
// Swift's keepCentered: the middle stays on the same part.
void JPEGPreview::setZoom(std::optional<double> zoom)
{
if (zoom == m_zoom)
return;
const QSizeF view(viewport()->size());
QPointF fraction(0.5, 0.5);
if (m_zoom) {
const QSizeF before = shownSize(*m_zoom);
fraction = QPointF((horizontalScrollBar()->value() + std::min(view.width(), before.width()) / 2) / before.width(),
(verticalScrollBar()->value() + std::min(view.height(), before.height()) / 2) / before.height());
}
m_zoom = zoom;
updateRanges();
if (m_zoom) {
const QSizeF size = shownSize(*m_zoom);
const QSizeF shown(viewport()->size());
const double x = std::clamp(fraction.x() * size.width() - shown.width() / 2, 0.0, std::max(0.0, size.width() - shown.width()));
const double y = std::clamp(fraction.y() * size.height() - shown.height() / 2, 0.0, std::max(0.0, size.height() - shown.height()));
horizontalScrollBar()->setValue(int(std::lround(x)));
verticalScrollBar()->setValue(int(std::lround(y)));
}
m_drag.reset();
showCursor(false);
viewport()->update();
m_zoomChanged();
}
// The image's size on screen at `zoom`, in points.
QSizeF JPEGPreview::shownSize(double zoom) const
{
return QSizeF(m_pixels) / std::max(1.0, devicePixelRatioF()) * zoom;
}
void JPEGPreview::updateRanges()
{
const QSizeF size = m_zoom ? shownSize(*m_zoom) : QSizeF(0, 0);
const QSize view = viewport()->size();
horizontalScrollBar()->setRange(0, std::max(0, int(std::ceil(size.width() - view.width()))));
horizontalScrollBar()->setPageStep(view.width());
verticalScrollBar()->setRange(0, std::max(0, int(std::ceil(size.height() - view.height()))));
verticalScrollBar()->setPageStep(view.height());
}
void JPEGPreview::showCursor(bool grabbing)
{
viewport()->setCursor(!m_zoom ? Qt::ArrowCursor : grabbing ? Qt::ClosedHandCursor : Qt::OpenHandCursor);
}
void JPEGPreview::paintEvent(QPaintEvent *)
{
QPainter painter(viewport());
const QRectF view(viewport()->rect());
painter.fillRect(view, QColor::fromRgbF(0.12f, 0.12f, 0.12f));
if (!m_image.isNull()) {
QRectF target;
if (m_zoom) {
// Smaller than the view, it sits in the middle.
const QSizeF size = shownSize(*m_zoom);
target = QRectF(QPointF(size.width() < view.width() ? (view.width() - size.width()) / 2 : -horizontalScrollBar()->value(),
size.height() < view.height() ? (view.height() - size.height()) / 2 : -verticalScrollBar()->value()),
size);
} else {
target = QRectF(QPointF(), QSizeF(m_image.size()).scaled(view.size(), Qt::KeepAspectRatio));
target.moveCenter(view.center());
}
// Nearest from 100% up: each JPEG pixel as it is.
if (m_zoom && *m_zoom >= 1) {
painter.drawImage(target, m_image);
} else {
// Swift's high quality: an area average, kept per size.
const QSize device = (target.size() * devicePixelRatioF()).toSize().expandedTo(QSize(1, 1));
if (m_reduced.size() != device)
m_reduced = m_image.scaled(device, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
painter.drawImage(target, m_reduced);
}
}
if (m_updating) {
// Swift's regular material behind the spinner.
QColor material = palette().color(QPalette::Window);
material.setAlphaF(0.85f);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(material);
QRectF plate(0, 0, 64, 64);
plate.moveCenter(view.center());
painter.drawRoundedRect(plate, 8, 8);
}
}
void JPEGPreview::resizeEvent(QResizeEvent *event)
{
QAbstractScrollArea::resizeEvent(event);
updateRanges();
}
void JPEGPreview::mousePressEvent(QMouseEvent *event)
{
if (m_zoom && event->button() == Qt::LeftButton)
m_drag = {event->position().toPoint(), QPoint(horizontalScrollBar()->value(), verticalScrollBar()->value())};
}
void JPEGPreview::mouseMoveEvent(QMouseEvent *event)
{
if (!m_drag)
return;
const QPoint moved = event->position().toPoint() - m_drag->first;
horizontalScrollBar()->setValue(m_drag->second.x() - moved.x());
verticalScrollBar()->setValue(m_drag->second.y() - moved.y());
showCursor(true);
}
void JPEGPreview::mouseReleaseEvent(QMouseEvent *)
{
m_drag.reset();
showCursor(false);
}
// Double-click switches between Fit and 100%.
void JPEGPreview::mouseDoubleClickEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
setZoom(m_zoom ? std::nullopt : std::optional(1.0));
}
+75 -45
View File
@@ -2,6 +2,7 @@
#include "UI/KeyboardShortcuts.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPickerSheet+Dialog.h"
#include "UI/LayerIcons.h"
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
@@ -9,6 +10,7 @@
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QShortcut>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
@@ -50,59 +52,46 @@ struct Encoded {
};
}
// Swift's ZStack: dark gray, the fitted preview, a plate.
class JPEGExportSheet::Preview : public QWidget {
public:
using QWidget::QWidget;
QImage image;
bool updating = false;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), QColor::fromRgbF(0.12f, 0.12f, 0.12f));
if (!image.isNull()) {
painter.setRenderHint(QPainter::SmoothPixmapTransform);
QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio));
target.moveCenter(QRectF(rect()).center());
painter.drawImage(target, image);
}
if (updating) {
// Swift's regular material behind the spinner.
QColor material = palette().color(QPalette::Window);
material.setAlphaF(0.85f);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(material);
QRectF plate(0, 0, 64, 64);
plate.moveCenter(QRectF(rect()).center());
painter.drawRoundedRect(plate, 8, 8);
}
}
};
JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent)
: QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)),
: QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new JPEGPreview(m_raster.image.size(), [this] { synchronize(); }, this)),
m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)),
m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new DialogColorSwatch(
QStringLiteral("JPEG Background"), [this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; },
[this](const PaletteColor &matte) { setMatte(matte); }, session, this)),
m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)),
m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this))
m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)),
m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)), m_zoomOut(new QPushButton(this))
{
m_options.quality = startQuality();
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this));
// The title row sits 8 above; it inherits 8.
auto *top = new QVBoxLayout;
top->setSpacing(8);
auto *title = new QHBoxLayout;
title->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this));
title->addStretch(1);
m_fit->setObjectName(QStringLiteral("jpegFit"));
m_zoomIn->setObjectName(QStringLiteral("jpegZoomIn"));
m_zoomIn->setAccessibleName(QStringLiteral("Zoom in"));
m_zoomOut->setObjectName(QStringLiteral("jpegZoomOut"));
m_zoomOut->setAccessibleName(QStringLiteral("Zoom out"));
for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) {
button->setAutoDefault(false);
title->addWidget(button);
}
connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); });
connect(m_zoomIn, &QPushButton::clicked, this, [this] { m_preview->zoomBy(1); });
connect(m_zoomOut, &QPushButton::clicked, this, [this] { m_preview->zoomBy(-1); });
top->addLayout(title);
m_preview->setObjectName(QStringLiteral("jpegPreview"));
m_preview->setFixedSize(560, 330);
m_spinner->setObjectName(QStringLiteral("jpegSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setGeometry(264, 149, 32, 32);
column->addWidget(m_preview);
top->addWidget(m_preview);
column->addLayout(top);
auto *quality = new QHBoxLayout;
auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this);
@@ -132,17 +121,22 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, st
matte->addWidget(m_matteSwatch);
matte->addStretch(1);
column->addLayout(matte);
column->addWidget(text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal,
QPalette::PlaceholderText, this));
// The size, the result and the buttons share one row.
auto *buttons = new QHBoxLayout;
buttons->setSpacing(12);
QLabel *size = text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal,
QPalette::PlaceholderText, this);
size->setObjectName(QStringLiteral("jpegSize"));
buttons->addWidget(size);
buttons->addStretch(1);
m_failure->setObjectName(QStringLiteral("jpegError"));
m_bytes->setObjectName(QStringLiteral("jpegBytes"));
m_note->setObjectName(QStringLiteral("jpegNote"));
m_note->setText(QStringLiteral("Updating…"));
buttons->addWidget(m_failure);
buttons->addWidget(m_bytes);
buttons->addWidget(m_note);
buttons->addStretch(1);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("jpegCancel"));
cancel->setAutoDefault(false);
@@ -169,9 +163,28 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, st
m_wait->setInterval(200);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode);
bindZoomKeys();
connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &JPEGExportSheet::bindZoomKeys);
request();
}
// A window-modal sheet blocks the menus: it takes their keys.
void JPEGExportSheet::bindZoomKeys()
{
const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1),
QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)};
const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); },
[this] { m_preview->zoomBy(1); }, [this] { m_preview->zoomBy(-1); }};
for (size_t index = 0; index < m_zoomKeys.size(); ++index) {
if (!m_zoomKeys[index]) {
m_zoomKeys[index] = new QShortcut(this);
connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]);
}
m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index]));
}
synchronize();
}
// SwiftUI cancels the task when the sheet goes.
JPEGExportSheet::~JPEGExportSheet()
{
@@ -233,20 +246,37 @@ void JPEGExportSheet::setMatte(const PaletteColor &matte)
request();
}
void JPEGExportSheet::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
synchronize();
}
void JPEGExportSheet::synchronize()
{
const bool ready = m_result && m_readyOptions == m_options;
const bool updating = m_readyOptions != m_options && !m_error;
m_preview->image = m_result ? m_result->preview : QImage();
m_preview->updating = updating;
m_preview->update();
m_preview->setImage(m_result ? m_result->preview : QImage(), updating);
m_spinner->setVisible(updating);
m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100)));
m_failure->setVisible(m_error.has_value());
m_failure->setText(m_error.value_or(QString()));
m_bytes->setVisible(ready && !m_error);
m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString());
m_note->setVisible(!m_error);
m_note->setText(ready ? QStringLiteral("· encoded preview, fitted to window") : QStringLiteral("Updating preview…"));
m_note->setVisible(!ready && !m_error);
m_export->setEnabled(ready && !m_error);
// The zoom buttons name their keys and the zoom.
const double zoom = m_preview->shownZoom();
const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100));
const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); };
m_fit->setEnabled(m_preview->zoom().has_value());
m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0)));
m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value());
m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas").arg(key(2), percent));
m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value());
m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent));
const QColor ink = palette().color(QPalette::ButtonText);
m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF()));
m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF()));
}
+60 -2
View File
@@ -1,7 +1,9 @@
#pragma once
#include "Document/ColorPalette.h"
#include "IO/ImageExporter.h"
#include <QAbstractScrollArea>
#include <QWidget>
#include <array>
#include <atomic>
#include <functional>
#include <memory>
@@ -9,12 +11,58 @@
class QLabel;
class QProgressBar;
class QShortcut;
class QPushButton;
class QSlider;
class QTimer;
class DialogColorSwatch;
class EditorSession;
// Swift's JPEGPreview: the encoded JPEG, fitted or zoomed, dragged.
class JPEGPreview : public QAbstractScrollArea {
Q_OBJECT
public:
static constexpr QSize frame{560, 330};
static constexpr std::array<double, 6> steps{0.25, 0.5, 1, 2, 4, 8};
// The zoom at which the whole image fits `frame`.
static double fitZoom(int width, int height, QSizeF frame, double displayScale);
// The next step past `zoom`: 1 in, −1 out.
static std::optional<double> step(double zoom, int direction);
// `pixels`: the export's size, which the image may undercut.
JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent);
void setImage(const QImage &image, bool updating);
// None fits; 1 is a screen pixel a pixel.
std::optional<double> zoom() const { return m_zoom; }
void setZoom(std::optional<double> zoom);
double shownZoom() const;
void zoomBy(int direction);
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
private:
QSizeF shownSize(double zoom) const;
void updateRanges();
void showCursor(bool grabbing);
const QSize m_pixels;
// Each change of zoom, for the sheet's buttons.
const std::function<void()> m_zoomChanged;
QImage m_image;
// The image shrunk to the size last drawn.
QImage m_reduced;
bool m_updating = false;
std::optional<double> m_zoom;
// A drag's press and the scroll it began from.
std::optional<std::pair<QPoint, QPoint>> m_drag;
};
// Swift's JPEGExportSheet: quality, matte and the encoded preview.
class JPEGExportSheet : public QWidget {
Q_OBJECT
@@ -25,12 +73,17 @@ public:
JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr);
~JPEGExportSheet() override;
protected:
// The zoom glyphs follow the theme.
void changeEvent(QEvent *event) override;
private:
class Preview;
void request();
void encode();
void setMatte(const PaletteColor &matte);
void synchronize();
// The View menu's zoom keys, as remapped, zoom the preview.
void bindZoomKeys();
const ExportRaster m_raster;
EditorSession &m_session;
@@ -42,7 +95,7 @@ private:
// The newest request's flag, Swift's task cancellation.
std::shared_ptr<std::atomic_bool> m_cancelled;
QTimer *const m_wait;
Preview *const m_preview;
JPEGPreview *const m_preview;
QProgressBar *const m_spinner;
QSlider *const m_quality;
QLabel *const m_percent;
@@ -51,4 +104,9 @@ private:
QLabel *const m_bytes;
QLabel *const m_note;
QPushButton *const m_export;
QPushButton *const m_fit;
QPushButton *const m_zoomIn;
QPushButton *const m_zoomOut;
// Fit, Actual Pixels, Zoom In and Zoom Out.
std::array<QShortcut *, 4> m_zoomKeys{};
};
+12
View File
@@ -241,6 +241,16 @@ void wind(QPainter &painter)
painter.drawPath(path);
}
// The lens toward the top left, its handle down right.
void magnifyingGlass(QPainter &painter, bool plus)
{
painter.drawEllipse(QPointF(7.5, 7.5), 5.5, 5.5);
painter.drawLine(QPointF(11.4, 11.4), QPointF(16, 16));
painter.drawLine(QPointF(5, 7.5), QPointF(10, 7.5));
if (plus)
painter.drawLine(QPointF(7.5, 5), QPointF(7.5, 10));
}
// Swift's circle.dotted: twelve dots round a ring.
void dottedCircle(QPainter &painter)
{
@@ -301,6 +311,8 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
case LayerIcon::drop: drop(painter); break;
case LayerIcon::wind: wind(painter); break;
case LayerIcon::dottedCircle: dottedCircle(painter); break;
case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break;
case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break;
}
painter.restore();
}
+1 -1
View File
@@ -8,7 +8,7 @@
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
scope, lineDiagonal, drop, wind, dottedCircle
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass
};
namespace LayerIcons {