Files
OmaPhoto/src/UI/CanvasSizeSheet.cpp
T

342 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
#include <QGridLayout>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
const std::array<QString, 9> 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<double, double> 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<void(std::optional<CanvasSizeOptions>)> 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<CanvasExtensionColor> 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<AnchorButton *>(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);
}