351 lines
16 KiB
C++
351 lines
16 KiB
C++
#include "UI/ImageSizeSheet.h"
|
||
#include "Document/DocumentLimits.h"
|
||
#include "UI/KeyboardShortcuts.h"
|
||
#include "UI/ColorPickerSheet.h"
|
||
#include "UI/NumericScrub.h"
|
||
#include <QCheckBox>
|
||
#include <QComboBox>
|
||
#include <QGridLayout>
|
||
#include <QLabel>
|
||
#include <QPushButton>
|
||
#include <QVBoxLayout>
|
||
#include <cmath>
|
||
|
||
namespace {
|
||
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
|
||
{
|
||
auto *label = new QLabel(words, parent);
|
||
label->setTextFormat(Qt::PlainText);
|
||
QFont font = label->font();
|
||
font.setPixelSize(pixels);
|
||
font.setWeight(weight);
|
||
label->setFont(font);
|
||
label->setForegroundRole(role);
|
||
return label;
|
||
}
|
||
|
||
// Swift's `.number`: three places at most, none trailing, ties even.
|
||
QString shown(double value, QLocale locale)
|
||
{
|
||
locale.setNumberOptions(QLocale::OmitGroupSeparator);
|
||
QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3);
|
||
while (number.endsWith(locale.zeroDigit()))
|
||
number.chop(locale.zeroDigit().size());
|
||
if (number.endsWith(locale.decimalPoint()))
|
||
number.chop(locale.decimalPoint().size());
|
||
return number;
|
||
}
|
||
|
||
QString grouped(qint64 value)
|
||
{
|
||
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
|
||
}
|
||
|
||
bool physical(const QString &unit)
|
||
{
|
||
return unit == QLatin1String("Inches") || unit == QLatin1String("Centimeters");
|
||
}
|
||
}
|
||
|
||
ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent)
|
||
: QWidget(parent), m_originalWidth(document.width), m_originalHeight(document.height), m_finish(std::move(finish)),
|
||
m_width(double(document.width)), m_height(double(document.height)), m_resolution(document.resolution), m_lastResolution(document.resolution), m_units(new QComboBox(this)),
|
||
m_widthField(dimension(true)), m_heightField(dimension(false)), m_lock(new QCheckBox(QStringLiteral("Lock aspect ratio"), this)),
|
||
m_resolutionField(new PickerField([this] {
|
||
bool number = false;
|
||
const double typed = m_resolutionField->locale().toDouble(m_resolutionField->text(), &number);
|
||
if (m_resolutionField->isModified() && number)
|
||
setResolution(typed);
|
||
m_resolutionField->setModified(false);
|
||
synchronize();
|
||
}, nullptr, this)),
|
||
m_samplingRow(new QWidget(this)), m_samplingChoice(new QComboBox(m_samplingRow)),
|
||
m_explanation(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
|
||
m_result(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), m_resize(new QPushButton(QStringLiteral("Resize"), this))
|
||
{
|
||
setFixedWidth(430);
|
||
auto *column = new QVBoxLayout(this);
|
||
column->setContentsMargins(24, 24, 24, 24);
|
||
column->setSpacing(18);
|
||
column->addWidget(text(QStringLiteral("Image Size"), 17, QFont::Bold, QPalette::WindowText, this));
|
||
column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal,
|
||
QPalette::PlaceholderText, this));
|
||
|
||
auto *fields = new QGridLayout;
|
||
fields->setHorizontalSpacing(8);
|
||
fields->setVerticalSpacing(18);
|
||
fields->setColumnMinimumWidth(0, 75);
|
||
auto *units = new QLabel(QStringLiteral("Units"), this);
|
||
units->setBuddy(m_units);
|
||
m_units->setObjectName(QStringLiteral("imageUnits"));
|
||
connect(m_units, &QComboBox::activated, this, [this](int index) {
|
||
// A wheel leaves the focus: pending typing commits first.
|
||
m_units->setFocus();
|
||
m_unit = m_units->itemText(index);
|
||
synchronize();
|
||
});
|
||
fields->addWidget(units, 0, 0);
|
||
fields->addWidget(m_units, 0, 1);
|
||
m_titles = {new QLabel(QStringLiteral("Width"), this), new QLabel(QStringLiteral("Height"), this)};
|
||
m_scrubs = {scrub(m_titles[0], true), scrub(m_titles[1], false)};
|
||
fields->addWidget(m_titles[0], 1, 0);
|
||
fields->addWidget(m_widthField, 1, 1);
|
||
fields->addWidget(m_titles[1], 2, 0);
|
||
fields->addWidget(m_heightField, 2, 1);
|
||
column->addLayout(fields);
|
||
|
||
m_lock->setObjectName(QStringLiteral("imageLocked"));
|
||
connect(m_lock, &QCheckBox::clicked, this, [this](bool on) {
|
||
m_locked = on;
|
||
synchronize();
|
||
});
|
||
column->addWidget(m_lock);
|
||
auto *resolution = new QHBoxLayout;
|
||
auto *resolutionTitle = new QLabel(QStringLiteral("Resolution"), this);
|
||
// Swift's scrubbable Resolution: whole pixels per inch.
|
||
new NumericScrub(resolutionTitle, {.sensitivity = 1, .low = 1, .high = 9600, .step = 1, .value = [this] { return m_resolution; },
|
||
.set = [this](double value) {
|
||
setResolution(value);
|
||
m_resolutionField->setModified(false);
|
||
synchronize();
|
||
}});
|
||
resolution->addWidget(resolutionTitle);
|
||
m_resolutionField->setObjectName(QStringLiteral("imageResolution"));
|
||
m_resolutionField->setPlaceholderText(QStringLiteral("Resolution"));
|
||
m_resolutionField->setAccessibleName(QStringLiteral("Resolution"));
|
||
resolution->addWidget(m_resolutionField, 1);
|
||
resolution->addWidget(text(QStringLiteral("pixels/inch"), 13, QFont::Normal, QPalette::PlaceholderText, this));
|
||
column->addLayout(resolution);
|
||
// Resample changes only by its own box.
|
||
auto *resample = new QCheckBox(QStringLiteral("Resample"), this);
|
||
resample->setObjectName(QStringLiteral("imageResample"));
|
||
resample->setChecked(true);
|
||
connect(resample, &QCheckBox::clicked, this, &ImageSizeSheet::setResample);
|
||
column->addWidget(resample);
|
||
|
||
m_samplingRow->setObjectName(QStringLiteral("imageSamplingRow"));
|
||
auto *samplingLayout = new QHBoxLayout(m_samplingRow);
|
||
samplingLayout->setContentsMargins(0, 0, 0, 0);
|
||
auto *sampling = new QLabel(QStringLiteral("Sampling"), m_samplingRow);
|
||
sampling->setBuddy(m_samplingChoice);
|
||
m_samplingChoice->setObjectName(QStringLiteral("imageSampling"));
|
||
for (const LayerSampling each : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high})
|
||
m_samplingChoice->addItem(rawValue(each), int(each));
|
||
connect(m_samplingChoice, &QComboBox::activated, this, [this](int index) {
|
||
m_sampling = LayerSampling(m_samplingChoice->itemData(index).toInt());
|
||
});
|
||
samplingLayout->addWidget(sampling);
|
||
samplingLayout->addWidget(m_samplingChoice, 1);
|
||
column->addWidget(m_samplingRow);
|
||
m_explanation->setObjectName(QStringLiteral("imageExplanation"));
|
||
m_explanation->setWordWrap(true);
|
||
column->addWidget(m_explanation);
|
||
m_result->setObjectName(QStringLiteral("imageResult"));
|
||
m_result->setWordWrap(true);
|
||
column->addWidget(m_result);
|
||
|
||
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
|
||
cancel->setObjectName(QStringLiteral("imageCancel"));
|
||
cancel->setAutoDefault(false);
|
||
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
|
||
m_resize->setObjectName(QStringLiteral("imageResize"));
|
||
m_resize->setDefault(true);
|
||
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
|
||
NativeShortcut::bind(*this, m_resize, cancel);
|
||
// Resize rests while the size is invalid: no guard needed.
|
||
connect(m_resize, &QPushButton::clicked, this, [this] {
|
||
m_finish(ImageSizeOptions{.width = qint64(std::round(m_width)), .height = qint64(std::round(m_height)), .resolution = m_resolution,
|
||
.sampling = m_sampling});
|
||
});
|
||
auto *buttons = new QHBoxLayout;
|
||
buttons->addWidget(cancel);
|
||
buttons->addStretch(1);
|
||
buttons->addWidget(m_resize);
|
||
column->addLayout(buttons);
|
||
synchronize();
|
||
}
|
||
|
||
// NaN and infinity fail every range test.
|
||
bool ImageSizeSheet::valid() const
|
||
{
|
||
const double width = std::round(m_width), height = std::round(m_height);
|
||
return m_resolution >= 1 && m_resolution <= 9600 && width >= 1 && width <= DocumentLimits::maxSide && height >= 1 && height <= DocumentLimits::maxSide
|
||
&& (!m_resample || width * height <= DocumentLimits::maxSurfacePixels);
|
||
}
|
||
|
||
double ImageSizeSheet::display(double pixels, qint64 original) const
|
||
{
|
||
if (m_unit == QLatin1String("Percent"))
|
||
return pixels / double(original) * 100;
|
||
if (m_unit == QLatin1String("Inches"))
|
||
return pixels / m_resolution;
|
||
if (m_unit == QLatin1String("Centimeters"))
|
||
return pixels / m_resolution * 2.54;
|
||
return pixels;
|
||
}
|
||
|
||
PickerField *ImageSizeSheet::dimension(bool isWidth)
|
||
{
|
||
auto *field = new PickerField([this, isWidth] {
|
||
PickerField &edited = isWidth ? *m_widthField : *m_heightField;
|
||
bool number = false;
|
||
const double typed = edited.locale().toDouble(edited.text(), &number);
|
||
if (edited.isModified() && number)
|
||
setDimension(typed, isWidth);
|
||
edited.setModified(false);
|
||
synchronize();
|
||
}, nullptr, this);
|
||
field->setObjectName(isWidth ? QStringLiteral("imageWidth") : QStringLiteral("imageHeight"));
|
||
field->setPlaceholderText(isWidth ? QStringLiteral("Width") : QStringLiteral("Height"));
|
||
field->setAccessibleName(isWidth ? QStringLiteral("Width") : QStringLiteral("Height"));
|
||
return field;
|
||
}
|
||
|
||
// Swift's binding: pixels, or without resampling the resolution.
|
||
void ImageSizeSheet::setDimension(double value, bool isWidth)
|
||
{
|
||
// A print size means nothing without a usable resolution.
|
||
if (!(std::isfinite(value) && value > 0) || !canScrubDimensions())
|
||
return;
|
||
if (!m_resample) {
|
||
setResolution((isWidth ? m_width : m_height) / value * (m_unit == QLatin1String("Centimeters") ? 2.54 : 1));
|
||
return;
|
||
}
|
||
double pixels = value;
|
||
if (m_unit == QLatin1String("Percent"))
|
||
pixels = value / 100 * double(isWidth ? m_originalWidth : m_originalHeight);
|
||
else if (m_unit == QLatin1String("Inches"))
|
||
pixels = value * m_resolution;
|
||
else if (m_unit == QLatin1String("Centimeters"))
|
||
pixels = value / 2.54 * m_resolution;
|
||
if (isWidth) {
|
||
if (m_locked)
|
||
m_height = pixels * m_height / m_width;
|
||
m_width = pixels;
|
||
} else {
|
||
if (m_locked)
|
||
m_width = pixels * m_width / m_height;
|
||
m_height = pixels;
|
||
}
|
||
}
|
||
|
||
void ImageSizeSheet::setResolution(double value)
|
||
{
|
||
m_resolution = value;
|
||
if (!(std::isfinite(value) && value > 0))
|
||
return;
|
||
// Scaled from the last usable one, past any invalid entry.
|
||
if (m_resample && physical(m_unit)) {
|
||
m_width *= value / m_lastResolution;
|
||
m_height *= value / m_lastResolution;
|
||
}
|
||
m_lastResolution = value;
|
||
}
|
||
|
||
NumericScrub *ImageSizeSheet::scrub(QLabel *title, bool isWidth)
|
||
{
|
||
// Whole units; synchronize sets the limits and the gate.
|
||
return new NumericScrub(title, {.sensitivity = 0, .low = 0, .high = 0, .step = 1,
|
||
.value = [this, isWidth] { return display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight); },
|
||
.set = [this, isWidth](double value) {
|
||
setDimension(value, isWidth);
|
||
(isWidth ? m_widthField : m_heightField)->setModified(false);
|
||
synchronize();
|
||
}});
|
||
}
|
||
|
||
bool ImageSizeSheet::canScrubDimensions() const
|
||
{
|
||
return !physical(m_unit) || (std::isfinite(m_resolution) && m_resolution > 0);
|
||
}
|
||
|
||
// Swift's scrubRange: pixel limits, or print sizes without resampling.
|
||
std::pair<double, double> ImageSizeSheet::scrubRange(bool isWidth) const
|
||
{
|
||
const double pixels = isWidth ? m_width : m_height, other = isWidth ? m_height : m_width;
|
||
if (!m_resample) {
|
||
const double multiplier = m_unit == QLatin1String("Centimeters") ? 2.54 : 1;
|
||
return {pixels * multiplier / 9600, pixels * multiplier};
|
||
}
|
||
const double minimum = m_locked ? std::max(1.0, pixels / other) : 1.0;
|
||
const double dimensionLimit = m_locked ? std::min(30000.0, 30000 * pixels / other) : 30000.0;
|
||
const double areaLimit = m_locked ? std::sqrt(100'000'000 * pixels / other) : 100'000'000 / other;
|
||
const double maximum = std::max(minimum, std::min(dimensionLimit, areaLimit));
|
||
const qint64 original = isWidth ? m_originalWidth : m_originalHeight;
|
||
return {display(minimum, original), display(maximum, original)};
|
||
}
|
||
|
||
double ImageSizeSheet::scrubSensitivity(bool isWidth) const
|
||
{
|
||
if (!m_resample)
|
||
return m_unit == QLatin1String("Centimeters") ? 0.0254 : 0.01;
|
||
if (m_unit == QLatin1String("Percent"))
|
||
return 100 / double(isWidth ? m_originalWidth : m_originalHeight);
|
||
if (m_unit == QLatin1String("Inches"))
|
||
return 1 / m_resolution;
|
||
if (m_unit == QLatin1String("Centimeters"))
|
||
return 2.54 / m_resolution;
|
||
return 1;
|
||
}
|
||
|
||
// Swift's onChange: without resampling the pixels stay the document's.
|
||
void ImageSizeSheet::setResample(bool enabled)
|
||
{
|
||
m_resample = enabled;
|
||
if (!enabled) {
|
||
m_width = double(m_originalWidth);
|
||
m_height = double(m_originalHeight);
|
||
m_locked = true;
|
||
if (!physical(m_unit))
|
||
m_unit = QStringLiteral("Inches");
|
||
}
|
||
synchronize();
|
||
}
|
||
|
||
void ImageSizeSheet::synchronize()
|
||
{
|
||
// Pixels and Percent need resampling, as Swift's filter.
|
||
QStringList shownUnits = {QStringLiteral("Pixels"), QStringLiteral("Percent"), QStringLiteral("Inches"), QStringLiteral("Centimeters")};
|
||
if (!m_resample)
|
||
shownUnits = {QStringLiteral("Inches"), QStringLiteral("Centimeters")};
|
||
QStringList listed;
|
||
for (int index = 0; index < m_units->count(); ++index)
|
||
listed << m_units->itemText(index);
|
||
if (listed != shownUnits) {
|
||
m_units->clear();
|
||
m_units->addItems(shownUnits);
|
||
}
|
||
m_units->setCurrentText(m_unit);
|
||
for (const auto &[field, isWidth] : {std::pair(m_widthField, true), std::pair(m_heightField, false)}) {
|
||
const bool scrubs = canScrubDimensions();
|
||
m_titles[isWidth ? 0 : 1]->setEnabled(scrubs);
|
||
if (scrubs) {
|
||
const auto [low, high] = scrubRange(isWidth);
|
||
m_scrubs[isWidth ? 0 : 1]->reshape(scrubSensitivity(isWidth), low, high);
|
||
}
|
||
// A field being typed in keeps its typing.
|
||
const QString number = shown(display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight), field->locale());
|
||
if (!field->isModified() && field->text() != number)
|
||
field->setText(number);
|
||
}
|
||
const QString resolution = shown(m_resolution, m_resolutionField->locale());
|
||
if (!m_resolutionField->isModified() && m_resolutionField->text() != resolution)
|
||
m_resolutionField->setText(resolution);
|
||
m_lock->setChecked(m_locked);
|
||
m_lock->setEnabled(m_resample);
|
||
m_samplingRow->setVisible(m_resample);
|
||
m_samplingChoice->setCurrentIndex(m_samplingChoice->findData(int(m_sampling)));
|
||
m_explanation->setText(m_resample ? QStringLiteral("Resizes layer pixels and applies existing transforms. Undo restores the originals.")
|
||
: QStringLiteral("Only print dimensions and resolution change. Pixels stay unchanged."));
|
||
const bool isValid = valid();
|
||
m_result->setText(isValid ? QStringLiteral("Result: %1 × %2 pixels").arg(grouped(qint64(std::round(m_width))), grouped(qint64(std::round(m_height))))
|
||
: QStringLiteral("Use 1–%1 pixels per side, up to %2 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSideText()).arg(DocumentLimits::maxSurfaceMegapixels()));
|
||
m_result->setForegroundRole(isValid ? QPalette::PlaceholderText : QPalette::BrightText);
|
||
m_resize->setEnabled(isValid);
|
||
}
|
||
|
||
ImageSizeSheet::~ImageSizeSheet()
|
||
{
|
||
releaseFocus(*this);
|
||
}
|