#include "UI/ImageSizeSheet.h" #include "Document/DocumentLimits.h" #include "UI/KeyboardShortcuts.h" #include "UI/ColorPickerSheet.h" #include "UI/NumericScrub.h" #include #include #include #include #include #include #include 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)> 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 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); }