428 lines
18 KiB
C++
428 lines
18 KiB
C++
#include "UI/LevelsSheet.h"
|
|
#include "UI/KeyboardShortcuts.h"
|
|
#include "Rendering/EyedropperIcon.h"
|
|
#include <QEvent>
|
|
#include <QFrame>
|
|
#include <QHBoxLayout>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPainterPath>
|
|
#include <QVBoxLayout>
|
|
#include <cmath>
|
|
|
|
namespace {
|
|
// The five entries: Swift's names, fields and decimals.
|
|
struct Entry {
|
|
const char *name;
|
|
double LevelRange::*value;
|
|
int decimals;
|
|
};
|
|
constexpr std::array<Entry, 5> entries{{{"Input black", &LevelRange::black, 0},
|
|
{"Gamma", &LevelRange::gamma, 2},
|
|
{"Input white", &LevelRange::white, 0},
|
|
{"Output black", &LevelRange::outputBlack, 0},
|
|
{"Output white", &LevelRange::outputWhite, 0}}};
|
|
|
|
// SwiftUI's gray, and its dark red, green and blue.
|
|
const QColor gray(142, 142, 147);
|
|
|
|
QColor colour(LevelsChannel channel)
|
|
{
|
|
switch (channel) {
|
|
case LevelsChannel::rgb: return gray;
|
|
case LevelsChannel::red: return QColor(255, 66, 69);
|
|
case LevelsChannel::green: return QColor(48, 209, 88);
|
|
case LevelsChannel::blue: return QColor(0, 145, 255);
|
|
}
|
|
throw std::logic_error("no such channel");
|
|
}
|
|
|
|
// Swift's .caption: ten points, in the secondary ink unless primary.
|
|
QLabel *caption(const QString &text, QWidget *parent, QPalette::ColorRole ink = QPalette::PlaceholderText)
|
|
{
|
|
auto *label = new QLabel(text, parent);
|
|
QFont small = label->font();
|
|
small.setPixelSize(10);
|
|
label->setFont(small);
|
|
label->setForegroundRole(ink);
|
|
return label;
|
|
}
|
|
|
|
// The channel's bins, scaled so spikes cannot flatten the rest.
|
|
class Histogram : public QWidget {
|
|
public:
|
|
Histogram(const EditorSession &session, QWidget *parent)
|
|
: QWidget(parent), m_session(session), m_loading(caption(QStringLiteral("Loading histogram…"), this, QPalette::WindowText))
|
|
{
|
|
setFixedHeight(150);
|
|
setToolTip(QStringLiteral("Linear histogram with automatic vertical scaling. Tall spikes may extend beyond the graph; all tones from 0 "
|
|
"to 255 remain included."));
|
|
m_loading->move(8, 8);
|
|
connect(&session, &EditorSession::changed, this, [this] { synchronize(); });
|
|
synchronize();
|
|
}
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
QPainter painter(this);
|
|
painter.fillRect(rect(), QColor(0, 0, 0, 64));
|
|
const std::optional<LevelsEdit> &edit = m_session.levels();
|
|
if (!edit)
|
|
return;
|
|
const std::array<double, 256> &bins = edit->histogram[size_t(edit->settings.channel)];
|
|
const double peak = LevelsHistogramDisplay::scale(bins);
|
|
if (peak <= 0)
|
|
return;
|
|
QPainterPath bars;
|
|
bars.setFillRule(Qt::WindingFill);
|
|
const double step = width() / 256.0;
|
|
for (size_t index = 0; index < 256; ++index) {
|
|
const double tall = height() * std::min(1.0, std::max(0.0, bins[index] / peak));
|
|
bars.addRect(QRectF(double(index) * step, height() - tall, step + 0.1, tall));
|
|
}
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
painter.fillPath(bars, colour(edit->settings.channel));
|
|
}
|
|
|
|
private:
|
|
void synchronize()
|
|
{
|
|
const std::optional<LevelsEdit> &edit = m_session.levels();
|
|
if (!edit)
|
|
return;
|
|
setAccessibleName(QStringLiteral("Original %1 histogram").arg(rawValue(edit->settings.channel)));
|
|
m_loading->setVisible(!edit->histogramReady);
|
|
update();
|
|
}
|
|
|
|
const EditorSession &m_session;
|
|
QLabel *const m_loading;
|
|
};
|
|
|
|
// Black to white across, over the output handles.
|
|
class Gradient : public QWidget {
|
|
public:
|
|
explicit Gradient(QWidget *parent) : QWidget(parent) { setFixedHeight(14); }
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
QPainter painter(this);
|
|
QLinearGradient across(QPointF(0, 0), QPointF(width(), 0));
|
|
across.setColorAt(0, Qt::black);
|
|
across.setColorAt(1, Qt::white);
|
|
painter.fillRect(rect(), across);
|
|
}
|
|
};
|
|
|
|
// Swift's handle: a triangle in a 22 by 20 frame.
|
|
class Handle : public QWidget {
|
|
public:
|
|
Handle(const QString &name, const QColor &fill, const QWidget &track, std::function<void(double)> drag)
|
|
: QWidget(track.parentWidget()), m_fill(fill), m_track(track), m_drag(std::move(drag))
|
|
{
|
|
setAccessibleName(name);
|
|
resize(22, 20);
|
|
}
|
|
// Centred on the tone, nine points down the track.
|
|
void place(double tone) { move(int(std::round(m_track.x() + tone / 255 * m_track.width() - 11)), m_track.y() - 1); }
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
QPainter painter(this);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
QPainterPath triangle(QPointF(11, 4.75));
|
|
triangle.lineTo(17, 15.25);
|
|
triangle.lineTo(5, 15.25);
|
|
triangle.closeSubpath();
|
|
// Swift's half-point gray shadow: Qt has no blur.
|
|
painter.strokePath(triangle, QPen(gray, 1));
|
|
painter.fillPath(triangle, m_fill);
|
|
}
|
|
void mousePressEvent(QMouseEvent *event) override
|
|
{
|
|
if (event->button() == Qt::LeftButton)
|
|
drag(event->position());
|
|
}
|
|
void mouseMoveEvent(QMouseEvent *event) override
|
|
{
|
|
if (event->buttons().testFlag(Qt::LeftButton))
|
|
drag(event->position());
|
|
}
|
|
|
|
private:
|
|
// The pointer's tone along the track, as Swift's named space.
|
|
void drag(QPointF point) { m_drag((mapToParent(point).x() - m_track.x()) / m_track.width() * 255); }
|
|
|
|
const QColor m_fill;
|
|
const QWidget &m_track;
|
|
const std::function<void(double)> m_drag;
|
|
};
|
|
|
|
// Swift's handles view: tones across, each triangle on top.
|
|
class Track : public QWidget {
|
|
public:
|
|
Track(const EditorSession &session, bool output, const std::function<void(size_t, double)> &drag, QWidget *parent)
|
|
: QWidget(parent), m_session(session), m_output(output)
|
|
{
|
|
setFixedHeight(20);
|
|
const QStringList names = output ? QStringList{QStringLiteral("Output black"), QStringLiteral("Output white")}
|
|
: QStringList{QStringLiteral("Input black"), QStringLiteral("Gamma"), QStringLiteral("Input white")};
|
|
for (qsizetype index = 0; index < names.size(); ++index) {
|
|
const QColor fill = index == 0 ? QColor(Qt::black) : index + 1 == names.size() ? QColor(Qt::white) : gray;
|
|
m_handles.push_back(new Handle(names[index], fill, *this, [drag, index](double tone) { drag(size_t(index), tone); }));
|
|
}
|
|
connect(&session, &EditorSession::changed, this, [this] { place(); });
|
|
}
|
|
|
|
protected:
|
|
void moveEvent(QMoveEvent *) override { place(); }
|
|
void resizeEvent(QResizeEvent *) override { place(); }
|
|
|
|
private:
|
|
void place()
|
|
{
|
|
const std::optional<LevelsEdit> &edit = m_session.levels();
|
|
if (!edit)
|
|
return;
|
|
const LevelRange range = edit->settings.current();
|
|
const std::vector<double> tones = m_output ? std::vector{range.outputBlack, range.outputWhite}
|
|
: std::vector{range.black, range.black + (range.white - range.black) * std::pow(0.5, range.gamma), range.white};
|
|
for (size_t index = 0; index < tones.size(); ++index)
|
|
m_handles[index]->place(tones[index]);
|
|
}
|
|
|
|
const EditorSession &m_session;
|
|
const bool m_output;
|
|
std::vector<Handle *> m_handles;
|
|
};
|
|
|
|
// Swift's drag: whole tones for ends, a fraction for gamma.
|
|
void dragHandle(LevelRange &range, bool output, size_t index, double tone)
|
|
{
|
|
const double x = std::min(255.0, std::max(0.0, tone));
|
|
if (output)
|
|
(index == 0 ? range.outputBlack : range.outputWhite) = std::round(x);
|
|
else if (index == 0)
|
|
range.black = std::min(range.white - 1, std::round(x));
|
|
else if (index == 2)
|
|
range.white = std::max(range.black + 1, std::round(x));
|
|
else
|
|
range.gamma = std::log(std::min(0.999, std::max(0.001, (x - range.black) / (range.white - range.black)))) / std::log(0.5);
|
|
}
|
|
|
|
// The eyedropper glyph in one ink, 14 points.
|
|
QIcon eyedropper(const QColor &ink, double ratio)
|
|
{
|
|
QPixmap pixmap(QSize(14, 14) * ratio);
|
|
pixmap.setDevicePixelRatio(ratio);
|
|
pixmap.fill(Qt::transparent);
|
|
QPainter painter(&pixmap);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
painter.scale(14.0 / 18, 14.0 / 18);
|
|
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
|
|
EyedropperIcon::paint(painter);
|
|
return QIcon(pixmap);
|
|
}
|
|
|
|
// Swift's HStack: spread by Spacers, or packed to the left.
|
|
QHBoxLayout *row(std::initializer_list<QWidget *> widgets, bool spread)
|
|
{
|
|
auto *layout = new QHBoxLayout;
|
|
for (QWidget *widget : widgets) {
|
|
if (spread && layout->count() > 0)
|
|
layout->addStretch(1);
|
|
layout->addWidget(widget);
|
|
}
|
|
if (!spread)
|
|
layout->addStretch(1);
|
|
return layout;
|
|
}
|
|
|
|
QVBoxLayout *stack(std::initializer_list<QWidget *> widgets, int spacing)
|
|
{
|
|
auto *layout = new QVBoxLayout;
|
|
layout->setSpacing(spacing);
|
|
for (QWidget *widget : widgets)
|
|
layout->addWidget(widget);
|
|
return layout;
|
|
}
|
|
}
|
|
|
|
LevelsSheet::LevelsSheet(EditorSession &session, QWidget *parent)
|
|
: QWidget(parent), m_session(session), m_channel(new QComboBox(this)), m_histogram(new Histogram(session, this)),
|
|
m_input(new Track(session, false, [this](size_t index, double tone) { dragTo(false, index, tone); }, this)),
|
|
m_gradient(new Gradient(this)), m_output(new Track(session, true, [this](size_t index, double tone) { dragTo(true, index, tone); }, this)),
|
|
m_fields{field(0), field(1), field(2), field(3), field(4)}, m_samples{new QPushButton(this), new QPushButton(this), new QPushButton(this)},
|
|
m_sampleHint(caption(QString(), this)), m_autos{new QPushButton(this), new QPushButton(this), new QPushButton(this)},
|
|
m_preview(new QCheckBox(QStringLiteral("Preview"), this)), m_reset(new QPushButton(QStringLiteral("Reset"), this)),
|
|
m_caption(caption(QString(), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_spinner(new QProgressBar(this)),
|
|
m_ok(new QPushButton(QStringLiteral("OK"), this))
|
|
{
|
|
setFixedWidth(440);
|
|
for (const LevelsChannel channel : allLevelsChannels)
|
|
m_channel->addItem(rawValue(channel));
|
|
connect(m_channel, &QComboBox::activated, this,
|
|
[this](int index) { change([index](LevelsSettings &settings) { settings.channel = allLevelsChannels[size_t(index)]; }); });
|
|
auto *picker = new QWidget(this);
|
|
picker->setFixedWidth(180);
|
|
auto *pickerRow = new QHBoxLayout(picker);
|
|
pickerRow->setContentsMargins(0, 0, 0, 0);
|
|
// The label names the picker, as Swift's Picker title.
|
|
auto *title = new QLabel(QStringLiteral("Channel"), picker);
|
|
title->setBuddy(m_channel);
|
|
pickerRow->addWidget(title);
|
|
pickerRow->addWidget(m_channel, 1);
|
|
m_histogram->setObjectName(QStringLiteral("levelsHistogram"));
|
|
m_input->setObjectName(QStringLiteral("levelsInputHandles"));
|
|
m_output->setObjectName(QStringLiteral("levelsOutputHandles"));
|
|
for (size_t index = 0; index < 3; ++index) {
|
|
m_samples[index]->setText(rawValue(allLevelsSamples[index]));
|
|
m_samples[index]->setCheckable(true);
|
|
m_samples[index]->setAutoDefault(false);
|
|
// A second click puts the eyedropper away.
|
|
connect(m_samples[index], &QPushButton::clicked, this, [this, index] {
|
|
const std::optional<LevelsSample> mode = allLevelsSamples[index];
|
|
m_session.setLevelsSampleMode(m_session.levels().value().sampleMode == mode ? std::nullopt : mode);
|
|
});
|
|
m_autos[index]->setText(rawValue(allLevelsAutos[index]));
|
|
m_autos[index]->setAutoDefault(false);
|
|
connect(m_autos[index], &QPushButton::clicked, this, [this, index] { m_session.autoLevels(allLevelsAutos[index]); });
|
|
}
|
|
m_sampleHint->setWordWrap(true);
|
|
new NativeShortcut(ShortcutChord(QStringLiteral("p"), 2), *this, *m_preview);
|
|
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateLevels(m_session.levels().value().settings, on); });
|
|
m_reset->setAutoDefault(false);
|
|
connect(m_reset, &QPushButton::clicked, this, [this] {
|
|
m_session.setLevelsSampleMode(std::nullopt);
|
|
change([](LevelsSettings &settings) { settings = LevelsSettings(); });
|
|
});
|
|
m_cancel->setAutoDefault(false);
|
|
m_ok->setDefault(true);
|
|
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
|
|
NativeShortcut::bind(*this, m_ok, m_cancel);
|
|
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelLevels(); });
|
|
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitLevels(); });
|
|
m_spinner->setRange(0, 0);
|
|
m_spinner->setFixedWidth(40);
|
|
m_spinner->setTextVisible(false);
|
|
auto *column = new QVBoxLayout(this);
|
|
column->setContentsMargins(24, 24, 24, 24);
|
|
column->setSpacing(16);
|
|
column->addWidget(picker);
|
|
column->addLayout(stack({m_histogram, m_input}, 0));
|
|
column->addLayout(row({m_fields[0]->parentWidget(), m_fields[1]->parentWidget(), m_fields[2]->parentWidget()}, true));
|
|
column->addLayout(stack({m_gradient, m_output}, 0));
|
|
column->addLayout(row({m_fields[3]->parentWidget(), m_fields[4]->parentWidget()}, true));
|
|
column->addLayout(row({caption(QStringLiteral("Sample"), this), m_samples[0], m_samples[1], m_samples[2]}, false));
|
|
column->addWidget(m_sampleHint);
|
|
QVBoxLayout *automatic = stack({caption(QStringLiteral("Auto"), this)}, 6);
|
|
automatic->addLayout(row({m_autos[0], m_autos[1], m_autos[2]}, false));
|
|
column->addLayout(automatic);
|
|
column->addLayout(row({m_preview, m_reset}, true));
|
|
column->addWidget(m_caption);
|
|
auto *divider = new QFrame(this);
|
|
divider->setFrameShape(QFrame::HLine);
|
|
divider->setForegroundRole(QPalette::Mid);
|
|
column->addWidget(divider);
|
|
QHBoxLayout *buttons = row({m_cancel, m_spinner}, true);
|
|
buttons->addWidget(m_ok);
|
|
column->addLayout(buttons);
|
|
connect(&m_session, &EditorSession::changed, this, &LevelsSheet::synchronize);
|
|
synchronize();
|
|
}
|
|
|
|
PickerField *LevelsSheet::field(size_t index)
|
|
{
|
|
const QString name = QString::fromLatin1(entries[index].name);
|
|
auto *box = new QWidget(this);
|
|
auto *layout = new QVBoxLayout(box);
|
|
layout->setContentsMargins(0, 0, 0, 0);
|
|
layout->setSpacing(5);
|
|
layout->addWidget(caption(name, box));
|
|
// A number in the user's locale applies; other text reverts.
|
|
auto *entry = new PickerField([this, index] {
|
|
PickerField &edited = *m_fields[index];
|
|
bool number = false;
|
|
const double typed = edited.locale().toDouble(edited.text(), &number);
|
|
if (edited.isModified() && number) {
|
|
change([&](LevelsSettings &settings) {
|
|
LevelRange range = settings.current();
|
|
range.*entries[index].value = typed;
|
|
settings.setCurrent(range);
|
|
});
|
|
}
|
|
edited.setModified(false);
|
|
synchronize();
|
|
}, nullptr, box);
|
|
entry->setObjectName(QStringLiteral("levels") + QString(name).remove(QLatin1Char(' ')));
|
|
entry->setPlaceholderText(name);
|
|
entry->setAccessibleName(name);
|
|
entry->setAlignment(Qt::AlignRight);
|
|
entry->setFixedWidth(80);
|
|
layout->addWidget(entry);
|
|
return entry;
|
|
}
|
|
|
|
void LevelsSheet::dragTo(bool output, size_t index, double tone)
|
|
{
|
|
change([&](LevelsSettings &settings) {
|
|
LevelRange range = settings.current();
|
|
dragHandle(range, output, index, tone);
|
|
settings.setCurrent(range);
|
|
});
|
|
m_fields[output ? 3 + index : index]->setModified(false);
|
|
synchronize();
|
|
}
|
|
|
|
void LevelsSheet::change(const std::function<void(LevelsSettings &)> &edit)
|
|
{
|
|
const std::optional<LevelsEdit> &levels = m_session.levels();
|
|
if (!levels)
|
|
return;
|
|
LevelsSettings settings = levels->settings;
|
|
edit(settings);
|
|
m_session.updateLevels(settings, levels->preview);
|
|
}
|
|
|
|
void LevelsSheet::changeEvent(QEvent *event)
|
|
{
|
|
QWidget::changeEvent(event);
|
|
if (event->type() == QEvent::PaletteChange)
|
|
synchronize();
|
|
}
|
|
|
|
void LevelsSheet::synchronize()
|
|
{
|
|
// Closed, the panel hides the sheet until the next edit.
|
|
const std::optional<LevelsEdit> &edit = m_session.levels();
|
|
if (!edit)
|
|
return;
|
|
const LevelRange range = edit->settings.current();
|
|
m_channel->setCurrentIndex(int(edit->settings.channel));
|
|
// An entry being typed in keeps its typing.
|
|
for (size_t index = 0; index < entries.size(); ++index) {
|
|
const QString shown = m_fields[index]->locale().toString(range.*entries[index].value, 'f', entries[index].decimals);
|
|
if (!m_fields[index]->isModified() && m_fields[index]->text() != shown)
|
|
m_fields[index]->setText(shown);
|
|
}
|
|
for (size_t index = 0; index < 3; ++index) {
|
|
const bool chosen = edit->sampleMode == allLevelsSamples[index];
|
|
m_samples[index]->setChecked(chosen);
|
|
m_samples[index]->setIcon(eyedropper(palette().color(chosen ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF()));
|
|
m_autos[index]->setEnabled(edit->histogramReady);
|
|
}
|
|
m_sampleHint->setVisible(edit->sampleMode.has_value());
|
|
if (edit->sampleMode)
|
|
m_sampleHint->setText(QStringLiteral("Click the original layer to set %1. Click the eyedropper again to stop.")
|
|
.arg(rawValue(*edit->sampleMode).toLower()));
|
|
m_preview->setChecked(edit->preview);
|
|
m_caption->setText(m_session.adjustmentOriginal() ? QStringLiteral("Underlying pixels · alpha-weighted histogram")
|
|
: m_session.selection() ? QStringLiteral("Original pixels · selection and alpha-weighted histogram")
|
|
: QStringLiteral("Original pixels · alpha-weighted histogram"));
|
|
m_spinner->setVisible(edit->committing);
|
|
setEnabled(!edit->committing);
|
|
}
|