#include "UI/CanvasSizeSheet.h" #include "Document/DocumentLimits.h" #include "UI/KeyboardShortcuts.h" #include "UI/ByteCounts.h" #include "UI/ColorPickerSheet+Dialog.h" #include "UI/ColorPickerSheet.h" #include "UI/NumericScrub.h" #include #include #include #include #include #include #include #include #include #include #include namespace { const std::array anchorNames = {QStringLiteral("Top left"), QStringLiteral("Top center"), QStringLiteral("Top right"), QStringLiteral("Middle left"), QStringLiteral("Center"), QStringLiteral("Middle right"), QStringLiteral("Bottom left"), QStringLiteral("Bottom center"), QStringLiteral("Bottom right")}; // Swift's scrubRange: the pixel limits, shown in the draft's unit. std::pair scrubRange(const CanvasSizeDraft &draft, bool widthAxis) { const double original = double(widthAxis ? draft.originalWidth : draft.originalHeight); const double other = double(widthAxis ? draft.originalHeight : draft.originalWidth); const double lower = draft.locked ? std::max(1.0, original / other) : 1.0; const double upper = draft.locked ? std::min(30000.0, 30000 * original / other) : 30000.0; const auto displayed = [&](double pixels) { const double difference = pixels - (draft.relative ? original : 0); switch (draft.unit) { case CanvasUnit::pixels: return difference; case CanvasUnit::percent: return difference / original * 100; case CanvasUnit::inches: return difference / draft.resolution; case CanvasUnit::centimeters: return difference / draft.resolution * 2.54; } throw std::logic_error("no such unit"); }; return {displayed(lower), displayed(upper)}; } // Swift's scrubSensitivity: a point is a pixel in any unit. double scrubSensitivity(const CanvasSizeDraft &draft, bool widthAxis) { switch (draft.unit) { case CanvasUnit::pixels: return 1; case CanvasUnit::percent: return 100 / double(widthAxis ? draft.originalWidth : draft.originalHeight); case CanvasUnit::inches: return 1 / draft.resolution; case CanvasUnit::centimeters: return 2.54 / draft.resolution; } throw std::logic_error("no such unit"); } 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); } // Swift's anchor: a circle, filled with the accent when chosen. class AnchorButton : public QToolButton { public: using QToolButton::QToolButton; bool chosen = false; protected: void paintEvent(QPaintEvent *event) override { QToolButton::paintEvent(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); QRectF dot(0, 0, 11, 11); dot.moveCenter(QRectF(rect()).center()); if (chosen) { painter.setPen(Qt::NoPen); painter.setBrush(palette().color(QPalette::Highlight)); } else { painter.setPen(QPen(palette().color(QPalette::PlaceholderText), 1.2)); } painter.drawEllipse(dot); } }; } CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, EditorSession &session, std::function)> finish, QWidget *parent) : QWidget(parent), m_session(session), m_foreground(session.foregroundColor()), m_background(session.backgroundColor()), m_finish(std::move(finish)), m_draft(document.width, document.height, document.resolution), m_width(dimension(true)), m_height(dimension(false)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), m_anchors(new QButtonGroup(this)), m_anchorName(text(QString(), 12, QFont::Bold, QPalette::WindowText, this)), m_extension(new QComboBox(this)), m_customRow(new QWidget(this)), m_customSwatch(new DialogColorSwatch( QStringLiteral("Extension Color"), [this] { return m_custom; }, [this](const PaletteColor &custom) { m_custom = custom; }, session, m_customRow)), m_ok(new QPushButton(QStringLiteral("OK"), this)) { setFixedWidth(450); auto *column = new QVBoxLayout(this); column->setContentsMargins(24, 24, 24, 24); column->setSpacing(16); column->addWidget(text(QStringLiteral("Canvas 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::WindowText, this)); column->addWidget(text(ByteCounts::memory(qint64(document.width) * document.height * 4) + QStringLiteral(" uncompressed RGBA canvas"), 12, QFont::Normal, QPalette::PlaceholderText, this)); auto *divider = new QFrame(this); divider->setFrameShape(QFrame::HLine); divider->setForegroundRole(QPalette::Mid); column->addWidget(divider); auto *fields = new QGridLayout; fields->setHorizontalSpacing(8); fields->setVerticalSpacing(16); fields->setColumnMinimumWidth(0, 60); auto *units = new QComboBox(this); auto *unitsLabel = new QLabel(QStringLiteral("Units"), this); unitsLabel->setBuddy(units); units->setObjectName(QStringLiteral("canvasUnits")); for (const CanvasUnit unit : {CanvasUnit::pixels, CanvasUnit::percent, CanvasUnit::inches, CanvasUnit::centimeters}) units->addItem(rawValue(unit), int(unit)); connect(units, &QComboBox::activated, this, [this, units](int index) { // A wheel leaves the focus: pending typing commits first. units->setFocus(); m_draft.unit = CanvasUnit(units->itemData(index).toInt()); synchronize(); }); fields->addWidget(unitsLabel, 0, 0); fields->addWidget(units, 0, 1); auto *width = new QLabel(QStringLiteral("Width"), this); auto *height = new QLabel(QStringLiteral("Height"), this); m_scrubs = {scrub(width, true), scrub(height, false)}; fields->addWidget(width, 1, 0); fields->addWidget(m_width, 1, 1); fields->addWidget(height, 2, 0); fields->addWidget(m_height, 2, 1); column->addLayout(fields); auto *relative = new QCheckBox(QStringLiteral("Relative to current dimensions"), this); relative->setObjectName(QStringLiteral("canvasRelative")); connect(relative, &QCheckBox::clicked, this, [this](bool on) { m_draft.relative = on; synchronize(); }); auto *locked = new QCheckBox(QStringLiteral("Lock original aspect ratio"), this); locked->setObjectName(QStringLiteral("canvasLocked")); // Swift's onChange: locking takes the width's proportions. connect(locked, &QCheckBox::clicked, this, [this](bool on) { m_draft.locked = on; if (on) m_draft.set(m_draft.displayed(true), true); synchronize(); }); column->addWidget(relative); column->addWidget(locked); m_note->setObjectName(QStringLiteral("canvasNote")); column->addWidget(m_note); auto *grid = new QGridLayout; grid->setSpacing(3); for (int index = 0; index < 9; ++index) { auto *button = new AnchorButton(this); button->setObjectName(QStringLiteral("anchor%1").arg(index)); button->setFixedSize(25, 25); button->setToolTip(anchorNames[size_t(index)]); button->setAccessibleName(anchorNames[size_t(index)]); m_anchors->addButton(button, index); grid->addWidget(button, index / 3, index % 3); } connect(m_anchors, &QButtonGroup::idClicked, this, [this](int index) { m_anchor = index; synchronize(); }); auto *anchorColumn = new QVBoxLayout; anchorColumn->setSpacing(8); anchorColumn->addWidget(new QLabel(QStringLiteral("Anchor"), this)); anchorColumn->addLayout(grid); m_anchorName->setObjectName(QStringLiteral("anchorName")); auto *explanation = text(QStringLiteral("Keeps this point fixed. Artwork is not scaled; cropped content remains outside the canvas."), 12, QFont::Normal, QPalette::PlaceholderText, this); explanation->setWordWrap(true); auto *named = new QVBoxLayout; named->setContentsMargins(0, 28, 0, 0); named->setSpacing(8); named->addWidget(m_anchorName); named->addWidget(explanation); named->addStretch(1); auto *anchorRow = new QHBoxLayout; anchorRow->setSpacing(24); anchorRow->addLayout(anchorColumn); anchorRow->addLayout(named, 1); column->addLayout(anchorRow); auto *extension = new QHBoxLayout; auto *extensionLabel = new QLabel(QStringLiteral("Canvas extension"), this); extensionLabel->setBuddy(m_extension); m_extension->setObjectName(QStringLiteral("canvasExtension")); m_extension->addItems({QStringLiteral("Transparent"), QStringLiteral("Foreground"), QStringLiteral("Background"), QStringLiteral("Black"), QStringLiteral("White"), QStringLiteral("Custom")}); connect(m_extension, &QComboBox::activated, this, &CanvasSizeSheet::synchronize); extension->addWidget(extensionLabel); extension->addWidget(m_extension, 1); column->addLayout(extension); // Its label and the app's picker on a swatch. m_customRow->setObjectName(QStringLiteral("extensionColorRow")); m_customSwatch->setObjectName(QStringLiteral("extensionColor")); m_customSwatch->setToolTip(QStringLiteral("Color for the added canvas")); auto *customLayout = new QHBoxLayout(m_customRow); customLayout->setContentsMargins(0, 0, 0, 0); customLayout->setSpacing(8); customLayout->addWidget(new QLabel(QStringLiteral("Extension color"), m_customRow)); customLayout->addWidget(m_customSwatch); customLayout->addStretch(1); column->addWidget(m_customRow); auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); cancel->setObjectName(QStringLiteral("canvasCancel")); cancel->setAutoDefault(false); connect(cancel, &QPushButton::clicked, this, [this] { DialogColorSwatch::closePicker(m_session); m_finish(std::nullopt); }); m_ok->setObjectName(QStringLiteral("canvasOK")); m_ok->setDefault(true); // Swift's configuredNativeShortcut: Return and Escape, as remapped. NativeShortcut::bind(*this, m_ok, cancel); // OK rests while the draft is invalid: no guard needed. connect(m_ok, &QPushButton::clicked, this, [this] { // An open picker's colour counts: it closes first. DialogColorSwatch::closePicker(m_session); m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor, .fill = fill()}); }); auto *buttons = new QHBoxLayout; buttons->addWidget(cancel); buttons->addStretch(1); buttons->addWidget(m_ok); column->addLayout(buttons); synchronize(); } // Swift's value binding: a readable number sets the draft. PickerField *CanvasSizeSheet::dimension(bool widthAxis) { auto *field = new PickerField([this, widthAxis] { PickerField &edited = widthAxis ? *m_width : *m_height; bool number = false; const double typed = edited.locale().toDouble(edited.text(), &number); if (edited.isModified() && number && std::isfinite(typed)) m_draft.set(typed, widthAxis); edited.setModified(false); synchronize(); }, nullptr, this); field->setObjectName(widthAxis ? QStringLiteral("canvasWidth") : QStringLiteral("canvasHeight")); field->setPlaceholderText(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height")); field->setAccessibleName(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height")); return field; } NumericScrub *CanvasSizeSheet::scrub(QLabel *title, bool widthAxis) { // Dragged sizes snap to whole units; synchronize sets the limits. return new NumericScrub(title, {.sensitivity = 1, .low = 0, .high = 0, .step = 1, .value = [this, widthAxis] { return m_draft.displayed(widthAxis); }, .set = [this, widthAxis](double value) { m_draft.set(value, widthAxis); (widthAxis ? m_width : m_height)->setModified(false); synchronize(); }}); } std::optional CanvasSizeSheet::fill() const { const auto colour = [](PaletteColor value) { return CanvasExtensionColor{value.red, value.green, value.blue}; }; switch (m_extension->currentIndex()) { case 0: return std::nullopt; case 1: return colour(m_foreground); case 2: return colour(m_background); case 3: return colour(PaletteColor::black()); case 4: return colour(PaletteColor::white()); default: return colour(m_custom); } } // Units, Relative and Lock change only through their own controls. void CanvasSizeSheet::synchronize() { for (const auto &[field, widthAxis] : {std::pair(m_width, true), std::pair(m_height, false)}) { // A field being typed in keeps its typing. const auto [low, high] = scrubRange(m_draft, widthAxis); m_scrubs[widthAxis ? 0 : 1]->reshape(scrubSensitivity(m_draft, widthAxis), low, high); const QString number = shown(m_draft.displayed(widthAxis), field->locale()); if (!field->isModified() && field->text() != number) field->setText(number); } const bool valid = m_draft.valid(); if (valid) { const qint64 width = qint64(std::round(m_draft.width)), height = qint64(std::round(m_draft.height)); m_note->setText(QStringLiteral("New: %1 × %2 pixels · %3 uncompressed").arg(grouped(width), grouped(height), ByteCounts::memory(width * height * 4))); } else { m_note->setText(QStringLiteral("Final dimensions must be 1–%1 pixels per side.").arg(DocumentLimits::maxSideText())); } m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText); for (QAbstractButton *each : m_anchors->buttons()) { auto *button = static_cast(each); button->chosen = m_anchors->id(each) == m_anchor; button->setAccessibleDescription(button->chosen ? QStringLiteral("Selected") : QString()); button->update(); } m_anchorName->setText(anchorNames[size_t(m_anchor)]); m_customRow->setVisible(m_extension->currentIndex() == 5); m_ok->setEnabled(valid); } CanvasSizeSheet::~CanvasSizeSheet() { releaseFocus(*this); DialogColorSwatch::closePicker(m_session); }