395 lines
15 KiB
C++
395 lines
15 KiB
C++
#include "UI/ColorPickerSheet.h"
|
|
#include "UI/NumericScrub.h"
|
|
#include "UI/KeyboardShortcuts.h"
|
|
#include <QFontDatabase>
|
|
#include <QGridLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QKeyEvent>
|
|
#include <QLabel>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPainterPath>
|
|
#include <QRegularExpressionValidator>
|
|
#include <QVBoxLayout>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
namespace {
|
|
constexpr double fieldSize = 256;
|
|
|
|
// A pointer drag reports each point it passes.
|
|
class Picking : public QWidget {
|
|
public:
|
|
Picking(const EditorSession &session, std::function<void(QPointF)> pick, QWidget *parent)
|
|
: QWidget(parent), m_session(session), m_pick(std::move(pick))
|
|
{
|
|
}
|
|
|
|
protected:
|
|
// Swift's drag answers the primary button alone.
|
|
void mousePressEvent(QMouseEvent *event) override
|
|
{
|
|
if (event->button() == Qt::LeftButton)
|
|
m_pick(event->position());
|
|
}
|
|
void mouseMoveEvent(QMouseEvent *event) override
|
|
{
|
|
if (event->buttons().testFlag(Qt::LeftButton))
|
|
m_pick(event->position());
|
|
}
|
|
const EditorSession &m_session;
|
|
|
|
private:
|
|
const std::function<void(QPointF)> m_pick;
|
|
};
|
|
|
|
// Swift's saturation and brightness field, its marker ringed.
|
|
class SaturationBrightness : public Picking {
|
|
public:
|
|
using Picking::Picking;
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
QPainter painter(this);
|
|
const QRectF rect(0, 0, fieldSize, fieldSize);
|
|
QLinearGradient across(rect.topLeft(), rect.topRight());
|
|
across.setColorAt(0, Qt::white);
|
|
across.setColorAt(1, PickerHSB(picker->hsb.hue, 1, 1).rgb().color());
|
|
painter.fillRect(rect, across);
|
|
QLinearGradient down(rect.topLeft(), rect.bottomLeft());
|
|
down.setColorAt(0, Qt::transparent);
|
|
down.setColorAt(1, Qt::black);
|
|
painter.fillRect(rect, down);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
painter.setBrush(Qt::NoBrush);
|
|
const QPointF centre(picker->hsb.saturation * fieldSize, (1 - picker->hsb.brightness) * fieldSize);
|
|
// Black behind, then white: Swift's ring and its background.
|
|
painter.setPen(QPen(Qt::black, 0.75));
|
|
painter.drawEllipse(centre, 6.375, 6.375);
|
|
painter.setPen(QPen(Qt::white, 1.5));
|
|
painter.drawEllipse(centre, 5.25, 5.25);
|
|
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
|
|
painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5));
|
|
}
|
|
};
|
|
|
|
// Swift's hue strip, 360 on top; the sheet draws arrows.
|
|
class HueStrip : public Picking {
|
|
public:
|
|
using Picking::Picking;
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
QPainter painter(this);
|
|
const QRectF strip(7, 0, 20, fieldSize);
|
|
QLinearGradient down(strip.topLeft(), strip.bottomLeft());
|
|
for (int step = 0; step <= 6; ++step)
|
|
down.setColorAt(step / 6.0, PickerHSB(360 - step * 60, 1, 1).rgb().color());
|
|
painter.fillRect(strip, down);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
|
|
painter.setBrush(Qt::NoBrush);
|
|
painter.drawRect(strip.adjusted(0.5, 0.5, -0.5, -0.5));
|
|
}
|
|
};
|
|
|
|
// The new colour, rounded, a 60% black rim.
|
|
class ColorPreview : public QWidget {
|
|
public:
|
|
ColorPreview(const EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session) {}
|
|
|
|
protected:
|
|
void paintEvent(QPaintEvent *) override
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
QPainter painter(this);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
QPainterPath shape;
|
|
shape.addRoundedRect(QRectF(rect()), 5, 5);
|
|
painter.fillPath(shape, picker->color().color());
|
|
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
|
|
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 4.5, 4.5);
|
|
}
|
|
|
|
private:
|
|
const EditorSession &m_session;
|
|
};
|
|
|
|
QLabel *label(const QString &text, QWidget *parent)
|
|
{
|
|
auto *label = new QLabel(text, parent);
|
|
label->setFixedWidth(14);
|
|
return label;
|
|
}
|
|
}
|
|
|
|
PickerField::PickerField(std::function<void()> commit, std::function<void(int)> step, QWidget *parent)
|
|
: QLineEdit(parent), m_commit(std::move(commit)), m_step(std::move(step))
|
|
{
|
|
}
|
|
|
|
void PickerField::keyPressEvent(QKeyEvent *event)
|
|
{
|
|
// Swift's arrowSteps: one a press, ten with Shift.
|
|
if (m_step && (event->key() == Qt::Key_Up || event->key() == Qt::Key_Down)) {
|
|
m_step((event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1));
|
|
return;
|
|
}
|
|
// Return commits, then goes on to OK, the default.
|
|
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
|
|
m_commit();
|
|
QLineEdit::keyPressEvent(event);
|
|
}
|
|
|
|
void releaseFocus(QWidget &owner)
|
|
{
|
|
// Destroyed focused, it would commit into freed members.
|
|
if (QWidget *focus = owner.focusWidget(); focus && focus->hasFocus())
|
|
focus->clearFocus();
|
|
}
|
|
|
|
void PickerField::focusOutEvent(QFocusEvent *event)
|
|
{
|
|
// A menu or popup borrows the focus: the typing stays.
|
|
if (event->reason() != Qt::MenuBarFocusReason && event->reason() != Qt::PopupFocusReason)
|
|
m_commit();
|
|
QLineEdit::focusOutEvent(event);
|
|
}
|
|
|
|
ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent)
|
|
: QWidget(parent), m_session(session),
|
|
m_field(new SaturationBrightness(session, [this](QPointF point) {
|
|
pick([point](PickerHSB &hsb) {
|
|
hsb.saturation = std::clamp(point.x() / fieldSize, 0.0, 1.0);
|
|
hsb.brightness = 1 - std::clamp(point.y() / fieldSize, 0.0, 1.0);
|
|
});
|
|
}, this)),
|
|
m_hue(new HueStrip(session, [this](QPointF point) {
|
|
pick([point](PickerHSB &hsb) { hsb.hue = (1 - std::clamp(point.y() / fieldSize, 0.0, 1.0)) * 360; });
|
|
}, this)),
|
|
m_preview(new ColorPreview(session, this)), m_channels{channel(0), channel(1), channel(2)},
|
|
m_hex(new PickerField([this] { commitHex(); }, nullptr, this)),
|
|
m_ok(new QPushButton(QStringLiteral("OK"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this))
|
|
{
|
|
m_field->setObjectName(QStringLiteral("saturationBrightness"));
|
|
m_field->setFixedSize(int(fieldSize), int(fieldSize));
|
|
m_field->setAccessibleName(QStringLiteral("Saturation and brightness"));
|
|
m_hue->setObjectName(QStringLiteral("hueStrip"));
|
|
m_hue->setFixedSize(34, int(fieldSize));
|
|
m_hue->setAccessibleName(QStringLiteral("Hue"));
|
|
m_preview->setObjectName(QStringLiteral("newColor"));
|
|
m_preview->setFixedSize(64, 64);
|
|
m_preview->setAccessibleName(QStringLiteral("New color"));
|
|
m_ok->setObjectName(QStringLiteral("pickerOK"));
|
|
m_ok->setDefault(true);
|
|
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
|
|
NativeShortcut::bind(*this, m_ok, m_cancel);
|
|
m_ok->setFixedWidth(90);
|
|
m_cancel->setObjectName(QStringLiteral("pickerCancel"));
|
|
m_cancel->setAutoDefault(false);
|
|
m_cancel->setFixedWidth(90);
|
|
connect(m_ok, &QPushButton::clicked, this, [finish] { finish(true); });
|
|
connect(m_cancel, &QPushButton::clicked, this, [finish] { finish(false); });
|
|
auto *buttons = new QVBoxLayout;
|
|
buttons->setSpacing(8);
|
|
buttons->addWidget(m_ok);
|
|
buttons->addWidget(m_cancel);
|
|
buttons->addStretch();
|
|
auto *top = new QHBoxLayout;
|
|
top->setSpacing(16);
|
|
top->addWidget(m_preview, 0, Qt::AlignTop);
|
|
top->addLayout(buttons);
|
|
auto *fields = new QGridLayout;
|
|
fields->setHorizontalSpacing(8);
|
|
fields->setVerticalSpacing(6);
|
|
for (int index = 0; index < 3; ++index) {
|
|
QLabel *name = label(QString(QStringLiteral("RGB").at(index)), this);
|
|
// Swift's scrubbable channel: whole levels, replacing typing.
|
|
new NumericScrub(name, {.sensitivity = 1, .low = 0, .high = 255, .step = 1,
|
|
.value = [this, index] {
|
|
const PaletteColor color = m_session.colorPicker().value().color();
|
|
return std::round((index == 0 ? color.red : index == 1 ? color.green : color.blue) * 255);
|
|
},
|
|
.set = [this, index](double value) {
|
|
setChannel(index, value);
|
|
m_channels[size_t(index)]->setModified(false);
|
|
synchronize();
|
|
}});
|
|
fields->addWidget(name, index, 0);
|
|
fields->addWidget(m_channels[size_t(index)], index, 1, Qt::AlignLeft);
|
|
}
|
|
m_hex->setObjectName(QStringLiteral("hex"));
|
|
m_hex->setPlaceholderText(QStringLiteral("Hex"));
|
|
m_hex->setAccessibleName(QStringLiteral("Hex color"));
|
|
m_hex->setFixedWidth(84);
|
|
m_hex->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
|
fields->addWidget(label(QStringLiteral("#"), this), 3, 0);
|
|
fields->addWidget(m_hex, 3, 1, Qt::AlignLeft);
|
|
auto *column = new QVBoxLayout;
|
|
column->setSpacing(0);
|
|
column->addLayout(top);
|
|
column->addStretch();
|
|
column->addSpacing(12);
|
|
column->addLayout(fields);
|
|
// A dialog covers the canvas: nothing to sample.
|
|
if (!m_session.pickingForDialog()) {
|
|
auto *hint = new QLabel(QStringLiteral("Click the canvas to sample"), this);
|
|
hint->setObjectName(QStringLiteral("pickerHint"));
|
|
QFont small = hint->font();
|
|
small.setPixelSize(10);
|
|
hint->setFont(small);
|
|
hint->setForegroundRole(QPalette::PlaceholderText);
|
|
column->addSpacing(8);
|
|
column->addWidget(hint);
|
|
}
|
|
auto *side = new QWidget(this);
|
|
side->setFixedSize(180, int(fieldSize));
|
|
side->setLayout(column);
|
|
column->setContentsMargins(0, 0, 0, 0);
|
|
auto *row = new QHBoxLayout(this);
|
|
row->setContentsMargins(20, 20, 20, 20);
|
|
row->setSpacing(14);
|
|
row->addWidget(m_field, 0, Qt::AlignTop);
|
|
row->addWidget(m_hue, 0, Qt::AlignTop);
|
|
row->addWidget(side, 0, Qt::AlignTop);
|
|
connect(&m_session, &EditorSession::changed, this, &ColorPickerSheet::synchronize);
|
|
synchronize();
|
|
}
|
|
|
|
PickerField *ColorPickerSheet::channel(int index)
|
|
{
|
|
auto *field = new PickerField([this, index] {
|
|
// Typed numbers clamp, as the binding; overflows are 255.
|
|
PickerField &edited = *m_channels[size_t(index)];
|
|
if (edited.isModified() && !edited.text().isEmpty()) {
|
|
bool fits = false;
|
|
const qlonglong typed = edited.text().toLongLong(&fits);
|
|
setChannel(index, fits ? double(typed) : 255);
|
|
}
|
|
synchronize();
|
|
}, [this, index](int step) {
|
|
const PaletteColor color = m_session.colorPicker().value().color();
|
|
const double value = std::round((index == 0 ? color.red : index == 1 ? color.green : color.blue) * 255);
|
|
setChannel(index, value + step);
|
|
m_channels[size_t(index)]->setModified(false);
|
|
synchronize();
|
|
}, this);
|
|
field->setObjectName(QString(QStringLiteral("rgb").at(index)));
|
|
// Swift's title shows while empty; the label names the channel.
|
|
field->setPlaceholderText(QString(QStringLiteral("RGB").at(index)));
|
|
field->setAccessibleName(QStringList{QStringLiteral("Red"), QStringLiteral("Green"), QStringLiteral("Blue")}.at(index));
|
|
field->setFixedWidth(52);
|
|
field->setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), field));
|
|
return field;
|
|
}
|
|
|
|
void ColorPickerSheet::change(const std::function<void(PickerHSB &)> &edit)
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
PickerHSB hsb = picker->hsb;
|
|
edit(hsb);
|
|
m_session.setColorPickerHSB(hsb);
|
|
}
|
|
|
|
void ColorPickerSheet::pick(const std::function<void(PickerHSB &)> &edit)
|
|
{
|
|
change(edit);
|
|
for (PickerField *channel : m_channels)
|
|
channel->setModified(false);
|
|
synchronize();
|
|
}
|
|
|
|
// Swift's channel binding: the 8-bit colour, one channel set.
|
|
void ColorPickerSheet::setChannel(int channel, double value)
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
PaletteColor rgb = picker->color();
|
|
(channel == 0 ? rgb.red : channel == 1 ? rgb.green : rgb.blue) = std::clamp(value, 0.0, 255.0) / 255;
|
|
change([&rgb](PickerHSB &hsb) { hsb.setRGB(rgb); });
|
|
}
|
|
|
|
void ColorPickerSheet::commitHex()
|
|
{
|
|
if (const std::optional<PaletteColor> parsed = PaletteColor::fromHex(m_hex->text()))
|
|
change([&parsed](PickerHSB &hsb) { hsb.setRGB(*parsed); });
|
|
if (m_session.colorPicker())
|
|
m_hex->setText(m_session.colorPicker()->color().hex());
|
|
}
|
|
|
|
void ColorPickerSheet::synchronize()
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
const PaletteColor color = picker->color();
|
|
const std::array<double, 3> values{color.red, color.green, color.blue};
|
|
// An entry being typed in keeps its typing.
|
|
for (size_t index = 0; index < 3; ++index) {
|
|
if (!m_channels[index]->hasFocus() || !m_channels[index]->isModified())
|
|
m_channels[index]->setText(QString::number(std::lround(values[index] * 255)));
|
|
}
|
|
// Swift's hex draft follows only while its field is unfocused.
|
|
if (!m_hex->hasFocus())
|
|
m_hex->setText(color.hex());
|
|
// Qt's plain widgets hold no value: the degrees describe it.
|
|
m_hue->setAccessibleDescription(QStringLiteral("%1 degrees").arg(std::lround(picker->hsb.hue)));
|
|
m_field->update();
|
|
// The arrows reach five points past the strip.
|
|
update(m_hue->geometry().adjusted(0, -5, 0, 5));
|
|
m_preview->update();
|
|
}
|
|
|
|
// The strip's arrows overflow it, as Swift's; widgets clip.
|
|
void ColorPickerSheet::paintEvent(QPaintEvent *)
|
|
{
|
|
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
|
|
if (!picker)
|
|
return;
|
|
QPainter painter(this);
|
|
painter.setRenderHint(QPainter::Antialiasing);
|
|
const QRectF strip(m_hue->geometry());
|
|
const double y = strip.top() + (1 - picker->hsb.hue / 360) * fieldSize;
|
|
QPainterPath arrows;
|
|
arrows.addPolygon(QPolygonF({QPointF(strip.left(), y - 5), QPointF(strip.left() + 7, y), QPointF(strip.left(), y + 5)}));
|
|
arrows.addPolygon(QPolygonF({QPointF(strip.right(), y - 5), QPointF(strip.right() - 7, y), QPointF(strip.right(), y + 5)}));
|
|
painter.fillPath(arrows, palette().color(QPalette::WindowText));
|
|
}
|
|
|
|
ColorPickerPanelController::ColorPickerPanelController(QWidget &owner) : m_panel(identifier(), owner) {}
|
|
|
|
void ColorPickerPanelController::show(const ColorPickerState &state, EditorSession &session)
|
|
{
|
|
// Its close button cancels; closing none does nothing.
|
|
m_panel.onClose = [&session] { session.closeColorPicker(false); };
|
|
m_panel.show(state.target.title(), new ColorPickerSheet(session, [&session](bool commit) { session.closeColorPicker(commit); }));
|
|
}
|
|
|
|
void ColorPickerPanelController::close()
|
|
{
|
|
m_panel.close();
|
|
}
|
|
|
|
void ColorPickerPanelController::refocus()
|
|
{
|
|
FloatingPanel::refocus(identifier());
|
|
}
|
|
|
|
ColorPickerSheet::~ColorPickerSheet()
|
|
{
|
|
releaseFocus(*this);
|
|
}
|