Files
OmaPhoto/src/UI/EffectsSheet.cpp
T

285 lines
14 KiB
C++

#include "UI/EffectsSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
#include <QHBoxLayout>
#include "UI/NumericScrub.h"
#include <QLabel>
#include <QLocale>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
// A thousand steps across each slider's range.
constexpr int travel = 1000;
// Swift's .number with no decimals, ties to even, never grouped.
QString shown(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
return locale.toString(std::nearbyint(value), 'f', 0);
}
QLabel *words(const QString &text, bool headline, QWidget *parent)
{
auto *label = new QLabel(text, parent);
if (headline) {
QFont font = label->font();
font.setPixelSize(13);
font.setWeight(QFont::DemiBold);
label->setFont(font);
}
return label;
}
std::optional<double> percent(const auto &effect)
{
return effect ? std::optional(effect->opacity * 100) : std::nullopt;
}
}
EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent)
: QWidget(parent), m_session(session), m_kind(kind), m_column(new QVBoxLayout(this))
{
setObjectName(QStringLiteral("effectsSheet"));
setFixedWidth(340);
m_column->setContentsMargins(20, 20, 20, 20);
m_column->setSpacing(16);
switch (kind) {
case LayerEffectKind::stroke:
header(QStringLiteral("Stroke"), true);
colour(true);
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.stroke ? std::optional(e.stroke->size) : std::nullopt; },
[](LayerEffects &e, double value) { e.stroke->size = value; }, 0, 20, QStringLiteral("px"), StrokeEffect::maxSize);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.stroke); },
[](LayerEffects &e, double value) { e.stroke->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
case LayerEffectKind::shadow:
header(QStringLiteral("Drop Shadow"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.shadow); },
[](LayerEffects &e, double value) { e.shadow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->angle) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->angle = value; }, -180, 180, QStringLiteral("°"));
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->distance) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 100, QStringLiteral("px"), 5000);
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->blur) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
break;
case LayerEffectKind::colorOverlay:
header(QStringLiteral("Color Overlay"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.colorOverlay); },
[](LayerEffects &e, double value) { e.colorOverlay->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
case LayerEffectKind::innerShadow:
header(QStringLiteral("Inner Shadow"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.innerShadow); },
[](LayerEffects &e, double value) { e.innerShadow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->angle) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->angle = value; }, -180, 180, QStringLiteral("°"));
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->distance) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 50, QStringLiteral("px"), 5000);
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->blur) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
break;
case LayerEffectKind::outerGlow:
header(QStringLiteral("Outer Glow"), false);
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.outerGlow ? std::optional(e.outerGlow->size) : std::nullopt; },
[](LayerEffects &e, double value) { e.outerGlow->size = value; }, 0, 100, QStringLiteral("px"), 500);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.outerGlow); },
[](LayerEffects &e, double value) { e.outerGlow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
case LayerEffectKind::innerGlow:
header(QStringLiteral("Inner Glow"), false);
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.innerGlow ? std::optional(e.innerGlow->size) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerGlow->size = value; }, 0, 100, QStringLiteral("px"), 500);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.innerGlow); },
[](LayerEffects &e, double value) { e.innerGlow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
}
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("effectsCancel"));
cancel->setAutoDefault(false);
auto *ok = new QPushButton(QStringLiteral("OK"), this);
ok->setObjectName(QStringLiteral("effectsOK"));
ok->setDefault(true);
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, ok, cancel);
connect(cancel, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(false); });
connect(ok, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(true); });
auto *buttons = new QHBoxLayout;
buttons->setSpacing(10);
buttons->addStretch(1);
buttons->addWidget(cancel);
buttons->addWidget(ok);
m_column->addLayout(buttons);
connect(&m_session, &EditorSession::changed, this, &EffectsSheet::synchronize);
synchronize();
}
// The kind's headline, then the stroke's position or the swatch.
void EffectsSheet::header(const QString &title, bool position)
{
auto *row = new QHBoxLayout;
row->addWidget(words(title, true, this));
row->addStretch(1);
if (!position) {
colour(false);
row->addWidget(m_controls.back());
} else {
auto *choice = new QWidget(this);
choice->setObjectName(QStringLiteral("strokePosition"));
choice->setAccessibleName(QStringLiteral("Position"));
auto *buttons = new QHBoxLayout(choice);
buttons->setContentsMargins(0, 0, 0, 0);
buttons->setSpacing(0);
m_position = new QButtonGroup(choice);
for (const int inside : {0, 1}) {
auto *button = new QToolButton(choice);
button->setText(inside ? QStringLiteral("Inside") : QStringLiteral("Outside"));
button->setCheckable(true);
m_position->addButton(button, inside);
buttons->addWidget(button);
}
connect(m_position, &QButtonGroup::idClicked, this, [this](int inside) {
m_session.changeEffects([inside](LayerEffects &effects) { effects.stroke->inside = inside == 1; });
});
row->addWidget(choice);
m_controls.push_back(choice);
}
m_column->addLayout(row);
}
// The effect's colour, opened in the app's own picker.
void EffectsSheet::colour(bool labelled)
{
auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 1, 1, this);
swatch->setObjectName(QStringLiteral("effectColor"));
swatch->setFixedSize(36, 18);
swatch->setToolTip(rawValue(m_kind) + QStringLiteral(" color"));
swatch->setAccessibleName(rawValue(m_kind) + QStringLiteral(" color"));
connect(swatch, &QAbstractButton::clicked, this, [this] { m_session.openEffectColorPicker(m_kind); });
if (!labelled) {
m_controls.push_back(swatch);
return;
}
auto *row = new QWidget(this);
auto *layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
auto *label = words(QStringLiteral("Color"), false, row);
label->setFixedWidth(64);
layout->addWidget(label);
layout->addWidget(swatch);
layout->addStretch(1);
m_column->addWidget(row);
m_controls.push_back(row);
}
void EffectsSheet::slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value,
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit,
std::optional<double> typedHigh)
{
const size_t index = m_sliders.size();
const QString name = title.toLower();
auto *row = new QWidget(this);
auto *layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
auto *label = words(title, false, row);
label->setFixedWidth(64);
auto *slider = new QSlider(Qt::Horizontal, row);
slider->setObjectName(name + QStringLiteral("Slider"));
slider->setRange(0, travel);
slider->setFixedWidth(130);
label->setBuddy(slider);
// Swift's scrubbable title, over the range typing reaches.
new NumericScrub(label, {.sensitivity = 1, .low = low, .high = typedHigh.value_or(high), .step = std::nullopt,
.value = [this, index] { return m_sliders[index].value(m_session.editingEffects()).value_or(m_sliders[index].low); },
.set = [this, index](double amount) {
const Slider &at = m_sliders[index];
apply(at, amount);
at.field->setModified(false);
synchronize();
}});
connect(slider, &QSlider::valueChanged, this, [this, index](int position) {
const Slider &at = m_sliders[index];
apply(at, at.low + (at.high - at.low) * position / travel);
// Its value replaces the field's typing, as a step does.
at.field->setModified(false);
synchronize();
});
// A readable number applies on Return or leaving; else reverts.
auto *field = new PickerField([this, index] {
PickerField &edited = *m_sliders[index].field;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number && std::isfinite(typed))
apply(m_sliders[index], typed);
edited.setModified(false);
synchronize();
}, [this, index](int step) {
const Slider &at = m_sliders[index];
if (const std::optional<double> current = at.value(m_session.editingEffects()))
apply(at, *current + step);
// The step replaces any typing, clamped or not.
at.field->setModified(false);
synchronize();
}, row);
field->setObjectName(name + QStringLiteral("Field"));
field->setAccessibleName(title);
field->setPlaceholderText(title);
field->setAlignment(Qt::AlignRight);
field->setFixedWidth(48);
auto *suffixed = new QHBoxLayout;
suffixed->setSpacing(2);
suffixed->addWidget(field);
suffixed->addWidget(new QLabel(unit, row));
layout->addWidget(label);
layout->addWidget(slider);
layout->addLayout(suffixed);
m_column->addWidget(row);
m_controls.push_back(row);
m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, typedHigh.value_or(high), slider, field});
}
void EffectsSheet::apply(const Slider &control, double value)
{
const double clamped = std::clamp(value, control.low, control.typedHigh);
m_session.changeEffects([&control, clamped](LayerEffects &effects) { control.change(effects, clamped); });
}
void EffectsSheet::synchronize()
{
// Swift's onChange: the open picker previews on the layer.
const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt;
if (picked != m_pickerColour) {
m_pickerColour = picked;
m_session.previewEffectColor();
}
const LayerEffects effects = m_session.editingEffects();
for (QWidget *control : m_controls) {
control->setVisible(effects.contains(m_kind));
control->update();
}
if (m_position && effects.stroke)
m_position->button(effects.stroke->inside ? 1 : 0)->setChecked(true);
for (const Slider &control : m_sliders) {
const std::optional<double> value = control.value(effects);
if (!value)
continue;
{
// QSlider pins a larger typed value at its end.
const QSignalBlocker quiet(control.slider);
control.slider->setValue(int(std::lround((*value - control.low) / (control.high - control.low) * travel)));
}
// A field being typed in keeps its typing.
const QString text = shown(*value, control.field->locale());
if (!control.field->isModified() && control.field->text() != text)
control.field->setText(text);
}
}