Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 06:30:26 -04:00
1 parent 462fe805b1
commit 0b7eb6cb6f
29 files changed
+1535 -71

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
#include "UI/ColorRangeSheet.h"
#include "UI/ColorPickerSheet.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/NumericScrub.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QPainter>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QVBoxLayout>
#include <cmath>
namespace {
QString rangeHelp(HueSampleMode mode)
{
switch (mode) {
case HueSampleMode::replace: return QStringLiteral("Click the image to select that color");
case HueSampleMode::add: return QStringLiteral("Click the image to add that color to the selection");
case HueSampleMode::remove: return QStringLiteral("Click the image to take that color out of the selection");
}
throw std::logic_error("unknown sample mode");
}
// The selection in black and white, shaped like the canvas.
class RangePreview : public QWidget {
public:
RangePreview(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session)
{
const QSizeF limit = ColorRangeEdit::previewSize;
const QSizeF size = m_session.colorRange() ? QSizeF(m_session.colorRange()->image.size()) : limit;
const double scale = std::min(limit.width() / size.width(), limit.height() / size.height());
setFixedSize(int(std::round(size.width() * scale)), int(std::round(size.height() * scale)));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), Qt::black);
if (m_session.colorRange() && !m_session.colorRange()->preview.isNull()) {
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.drawImage(QRectF(rect()), m_session.colorRange()->preview);
}
// Swift's strokeBorder: a white line inside, at 20%.
painter.setPen(QPen(QColor(255, 255, 255, 51), 1));
painter.drawRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5));
}
private:
EditorSession &m_session;
};
}
ColorRangeSheet::ColorRangeSheet(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_preview(new RangePreview(session, this)), m_caption(new QLabel(this)), m_slider(new QSlider(Qt::Horizontal, this)),
m_field(new PickerField(
[this] {
bool number = false;
const double typed = m_field->locale().toDouble(m_field->text(), &number);
if (m_field->isModified() && number && std::isfinite(typed))
m_session.setColorRangeFuzziness(typed);
m_field->setModified(false);
synchronize();
},
nullptr, this)),
m_invert(new QCheckBox(QStringLiteral("Invert"), this)), m_error(new QLabel(this)), m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(340);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
auto *droppers = new QHBoxLayout;
droppers->setSpacing(6);
for (size_t index = 0; index < allHueSampleModes.size(); ++index) {
const HueSampleMode mode = allHueSampleModes[index];
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { sampleEyedropper(painter, mode, ink); }, this);
button->setObjectName(QStringLiteral("colorRange") + rawValue(mode));
button->setToolTip(rangeHelp(mode));
button->setAccessibleName(rawValue(mode) + QStringLiteral(" color"));
connect(button, &QAbstractButton::clicked, this, [this, mode] { m_session.setColorRangeSampleMode(mode); });
m_eyedroppers[index] = button;
droppers->addWidget(button);
}
droppers->addStretch(1);
column->addLayout(droppers);
column->addWidget(m_preview, 0, Qt::AlignHCenter);
m_caption->setObjectName(QStringLiteral("colorRangeCaption"));
m_caption->setWordWrap(true);
QFont callout = m_caption->font();
callout.setPixelSize(12);
m_caption->setFont(callout);
m_caption->setForegroundRole(QPalette::PlaceholderText);
column->addWidget(m_caption);
// Fuzziness: a scrubbable title, the slider, the field.
auto *row = new QWidget(this);
row->setToolTip(QStringLiteral("How far a color may be from the picked ones and still be selected"));
auto *fuzziness = new QHBoxLayout(row);
fuzziness->setContentsMargins(0, 0, 0, 0);
fuzziness->setSpacing(10);
auto *title = new QLabel(QStringLiteral("Fuzziness"), row);
new NumericScrub(title, {.sensitivity = 1, .low = ColorRangeEdit::fuzzinessLow, .high = ColorRangeEdit::fuzzinessHigh, .step = std::nullopt,
.value = [this] { return m_session.colorRange() ? m_session.colorRange()->fuzziness : 40; },
.set = [this](double value) {
// Its value replaces the field's typing.
m_field->setModified(false);
m_session.setColorRangeFuzziness(value);
}});
title->setBuddy(m_slider);
m_slider->setObjectName(QStringLiteral("fuzzinessSlider"));
m_slider->setRange(int(ColorRangeEdit::fuzzinessLow), int(ColorRangeEdit::fuzzinessHigh));
connect(m_slider, &QSlider::valueChanged, this, [this](int value) {
m_field->setModified(false);
m_session.setColorRangeFuzziness(value);
});
m_field->setObjectName(QStringLiteral("fuzzinessField"));
m_field->setAccessibleName(QStringLiteral("Fuzziness"));
m_field->setPlaceholderText(QStringLiteral("Fuzziness"));
m_field->setAlignment(Qt::AlignRight);
m_field->setFixedWidth(48);
fuzziness->addWidget(title);
fuzziness->addWidget(m_slider, 1);
fuzziness->addWidget(m_field);
column->addWidget(row);
m_invert->setObjectName(QStringLiteral("colorRangeInvert"));
m_invert->setToolTip(QStringLiteral("Select everything except those colors, such as all but a green screen"));
connect(m_invert, &QCheckBox::clicked, this, [this](bool checked) { m_session.setColorRangeInvert(checked); });
column->addWidget(m_invert);
m_error->setObjectName(QStringLiteral("colorRangeError"));
m_error->setWordWrap(true);
m_error->setForegroundRole(QPalette::BrightText);
column->addWidget(m_error);
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
column->addWidget(divider);
auto *buttons = new QHBoxLayout;
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_session.cancelColorRange(); });
m_ok->setDefault(true);
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitColorRange(); });
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
column->addLayout(buttons);
NativeShortcut::bind(*this, m_ok, cancel);
connect(&m_session, &EditorSession::changed, this, &ColorRangeSheet::synchronize);
synchronize();
}
ColorRangeSheet::~ColorRangeSheet()
{
releaseFocus(*this);
}
void ColorRangeSheet::synchronize()
{
const std::optional<ColorRangeEdit> &edit = m_session.colorRange();
if (!edit)
return;
for (size_t index = 0; index < m_eyedroppers.size(); ++index)
m_eyedroppers[index]->setChecked(edit->effectiveMode() == allHueSampleModes[index]);
m_caption->setText(edit->hasColors() ? QStringLiteral("Shift-click adds a color, Alt-click takes one away.")
: QStringLiteral("Click the image to pick the color to select."));
{
const QSignalBlocker quiet(m_slider);
m_slider->setValue(int(edit->fuzziness));
}
if (!m_field->hasFocus() || !m_field->isModified())
m_field->setText(m_field->locale().toString(edit->fuzziness, 'f', 0));
m_invert->setChecked(edit->invert);
m_error->setText(edit->error.value_or(QString()));
m_error->setVisible(edit->error.has_value());
m_preview->update();
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "Document/EditorSession.h"
#include <QWidget>
#include <array>
class QCheckBox;
class QLabel;
class QPushButton;
class QSlider;
class PickerField;
class SampleButton;
// Swift's ColorRangeSheet: eyedroppers, preview, Fuzziness, Invert.
class ColorRangeSheet : public QWidget {
Q_OBJECT
public:
explicit ColorRangeSheet(EditorSession &session, QWidget *parent = nullptr);
~ColorRangeSheet() override;
private:
void synchronize();
EditorSession &m_session;
std::array<SampleButton *, 3> m_eyedroppers{};
QWidget *m_preview;
QLabel *m_caption;
QSlider *m_slider;
PickerField *m_field;
QCheckBox *m_invert;
QLabel *m_error;
QPushButton *m_ok;
};
+2
View File
@@ -198,6 +198,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
});
add(select, QStringLiteral("subject"), QStringLiteral("Subject"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_A),
[this] { session().selectSubject(SelectionMode::replace, {}); });
add(select, QStringLiteral("colorRange"), QStringLiteral("Color Range…"), QKeySequence(), [this] { session().beginColorRange(); });
add(select, QStringLiteral("maskBlackAreas"), QStringLiteral("Mask's Black Areas"), QKeySequence(), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().loadMaskSelection(*id);
@@ -380,6 +381,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("layerPixels"))->setEnabled(active && active->asset && s.canEditSelection());
action(QStringLiteral("maskBlackAreas"))->setEnabled(active && active->mask && s.canEditSelection());
action(QStringLiteral("subject"))->setEnabled(s.canSelectSubject());
action(QStringLiteral("colorRange"))->setEnabled(s.canSelectColorRange());
for (const char *name : {"expandSelection", "contractSelection", "featherSelection"})
action(QString::fromLatin1(name))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
+2 -54
View File
@@ -3,6 +3,7 @@
#include "Rendering/EyedropperIcon.h"
#include "UI/ColorPickerSheet.h"
#include "UI/NumericScrub.h"
#include "UI/SampleButton.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
@@ -62,59 +63,6 @@ HueSaturationSettings current(const EditorSession &session)
return session.hueSaturation() ? session.hueSaturation()->settings : HueSaturationSettings();
}
// Swift's plain sampling buttons: a glyph, tinted when chosen.
class SampleButton : public QToolButton {
public:
SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph))
{
setCheckable(true);
setFixedSize(24, 20);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (isChecked()) {
QColor tint = palette().color(QPalette::Highlight);
tint.setAlphaF(0.25f);
painter.setPen(Qt::NoPen);
painter.setBrush(tint);
painter.drawRoundedRect(QRectF(rect()), 4, 4);
}
m_glyph(painter, palette().color(QPalette::WindowText));
}
private:
const std::function<void(QPainter &, const QColor &)> m_glyph;
};
// Swift's eyedropper, badged plus or minus for Add and Remove.
void eyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink)
{
// A 14-point glyph, centred in the 24 by 20 frame.
painter.save();
painter.translate(5, 3);
painter.save();
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
painter.restore();
if (mode != HueSampleMode::replace) {
// An 8-point disc, bottom right, nudged 3 and 1.
QPainterPath badge;
badge.addEllipse(QRectF(9, 7, 8, 8));
QPainterPath sign;
sign.setFillRule(Qt::WindingFill);
sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2));
if (mode == HueSampleMode::add)
sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5));
painter.fillPath(badge.subtracted(sign), ink);
}
painter.restore();
}
// Swift's hand.point.up.left: a finger up and left, outlined.
void pointingHand(QPainter &painter, const QColor &ink)
{
@@ -133,7 +81,7 @@ void pointingHand(QPainter &painter, const QColor &ink)
QToolButton *sampleButton(HueSampleMode mode, QWidget *parent)
{
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { eyedropper(painter, mode, ink); }, parent);
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { sampleEyedropper(painter, mode, ink); }, parent);
button->setObjectName(QStringLiteral("hue") + rawValue(mode));
button->setToolTip(help(mode));
button->setAccessibleName(rawValue(mode) + QStringLiteral(" color"));
+48
View File
@@ -0,0 +1,48 @@
#include "UI/SampleButton.h"
#include "Rendering/EyedropperIcon.h"
#include <QPainter>
#include <QPainterPath>
SampleButton::SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph))
{
setCheckable(true);
setFixedSize(24, 20);
}
void SampleButton::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (isChecked()) {
QColor tint = palette().color(QPalette::Highlight);
tint.setAlphaF(0.25f);
painter.setPen(Qt::NoPen);
painter.setBrush(tint);
painter.drawRoundedRect(QRectF(rect()), 4, 4);
}
m_glyph(painter, palette().color(QPalette::WindowText));
}
void sampleEyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink)
{
// A 14-point glyph, centred in the 24 by 20 frame.
painter.save();
painter.translate(5, 3);
painter.save();
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
painter.restore();
if (mode != HueSampleMode::replace) {
// An 8-point disc, bottom right, nudged 3 and 1.
QPainterPath badge;
badge.addEllipse(QRectF(9, 7, 8, 8));
QPainterPath sign;
sign.setFillRule(Qt::WindingFill);
sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2));
if (mode == HueSampleMode::add)
sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5));
painter.fillPath(badge.subtracted(sign), ink);
}
painter.restore();
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "Document/HueSaturation.h"
#include <QToolButton>
#include <functional>
// Swift's plain sampling buttons: a glyph, tinted when chosen.
class SampleButton : public QToolButton {
Q_OBJECT
public:
SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent);
protected:
void paintEvent(QPaintEvent *event) override;
private:
const std::function<void(QPainter &, const QColor &)> m_glyph;
};
// Swift's eyedropper, badged plus or minus for Add and Remove.
void sampleEyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink);