diff --git a/ChangeLog b/ChangeLog
index c77915e90c..f0bd06ae32 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,5 +1,5 @@
Unreleased Version 2.2.2-pre
- * Feature: Color circle dock. Similar to Krita's Artistic Color Selector and MyPaint's HSV/HCY Wheel.
+ * Feature: Color circle dock with gamut masks, available through View > Docks > Color Circle. Similar to Krita's Artistic Color Selector and MyPaint's HSV/HCY Wheel.
2024-11-06 Version 2.2.2-beta.4
* Fix: Solve rendering glitches with selection outlines that happen on some systems. Thanks xxxx for reporting.
diff --git a/src/desktop/assets/gamutmasks/atmospherewithaccent.svg b/src/desktop/assets/gamutmasks/atmospherewithaccent.svg
new file mode 100644
index 0000000000..04f7b3bed8
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/atmospherewithaccent.svg
@@ -0,0 +1,44 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/atmospherictriad.svg b/src/desktop/assets/gamutmasks/atmospherictriad.svg
new file mode 100644
index 0000000000..cffeef0312
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/atmospherictriad.svg
@@ -0,0 +1,34 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/complementary.svg b/src/desktop/assets/gamutmasks/complementary.svg
new file mode 100644
index 0000000000..e13de85caf
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/complementary.svg
@@ -0,0 +1,34 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/dominanthuewithaccent.svg b/src/desktop/assets/gamutmasks/dominanthuewithaccent.svg
new file mode 100644
index 0000000000..e0e48ebe41
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/dominanthuewithaccent.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/shiftedtriad.svg b/src/desktop/assets/gamutmasks/shiftedtriad.svg
new file mode 100644
index 0000000000..fea24072cc
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/shiftedtriad.svg
@@ -0,0 +1,34 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/split.svg b/src/desktop/assets/gamutmasks/split.svg
new file mode 100644
index 0000000000..8ae5e23f63
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/split.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/splitcomplementary.svg b/src/desktop/assets/gamutmasks/splitcomplementary.svg
new file mode 100644
index 0000000000..003ce5269e
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/splitcomplementary.svg
@@ -0,0 +1,48 @@
+
+
diff --git a/src/desktop/assets/gamutmasks/tetradic.svg b/src/desktop/assets/gamutmasks/tetradic.svg
new file mode 100644
index 0000000000..95f45ad610
--- /dev/null
+++ b/src/desktop/assets/gamutmasks/tetradic.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/src/desktop/dialogs/artisticcolorwheeldialog.cpp b/src/desktop/dialogs/artisticcolorwheeldialog.cpp
index d211085241..ee345f78c0 100644
--- a/src/desktop/dialogs/artisticcolorwheeldialog.cpp
+++ b/src/desktop/dialogs/artisticcolorwheeldialog.cpp
@@ -4,8 +4,13 @@
#include "desktop/utils/widgetutils.h"
#include "desktop/widgets/artisticcolorwheel.h"
#include "desktop/widgets/kis_slider_spin_box.h"
+#include "libshared/util/paths.h"
#include
#include
+#include
+#include
+#include
+#include
#include
namespace dialogs {
@@ -15,7 +20,7 @@ ArtisticColorWheelDialog::ArtisticColorWheelDialog(QWidget *parent)
{
setWindowTitle(tr("Color Circle Settings"));
setWindowModality(Qt::WindowModal);
- resize(350, 500);
+ resize(350, 550);
QVBoxLayout *layout = new QVBoxLayout(this);
const desktop::settings::Settings &settings = dpApp().settings();
@@ -115,6 +120,37 @@ ArtisticColorWheelDialog::ArtisticColorWheelDialog(QWidget *parent)
}
}
+ QString gamutMaskPath = settings.colorCircleGamutMaskPath();
+ m_gamutMaskCombo = new QComboBox;
+ m_gamutMaskCombo->addItem(tr("No gamut mask"));
+ loadGamutMasks();
+ if(!gamutMaskPath.isEmpty()) {
+ int count = m_gamutMaskCombo->count();
+ for(int i = 1; i < count; ++i) {
+ if(m_gamutMaskCombo->itemData(i).toString() == gamutMaskPath) {
+ m_gamutMaskCombo->setCurrentIndex(i);
+ break;
+ }
+ }
+ }
+ layout->addWidget(m_gamutMaskCombo);
+ connect(
+ m_gamutMaskCombo, QOverload::of(&QComboBox::currentIndexChanged),
+ this, &ArtisticColorWheelDialog::updateGamutMask);
+
+ int gamutMaskAngle = settings.colorCircleGamutMaskAngle();
+ m_gamutMaskAngleSlider = new KisSliderSpinBox;
+ m_gamutMaskAngleSlider->setPrefix(tr("Mask angle: "));
+ //: Degree symbol. Unless your language uses a different one, keep as-is.
+ m_gamutMaskAngleSlider->setSuffix(tr("°"));
+ m_gamutMaskAngleSlider->setRange(0, 359);
+ m_gamutMaskAngleSlider->setValue(gamutMaskAngle);
+ layout->addWidget(m_gamutMaskAngleSlider);
+ connect(
+ m_gamutMaskAngleSlider,
+ QOverload::of(&KisSliderSpinBox::valueChanged), this,
+ &ArtisticColorWheelDialog::updateGamutMaskAngle);
+
m_wheel = new widgets::ArtisticColorWheel;
m_wheel->setHueLimit(hueLimit);
m_wheel->setHueCount(hueCount);
@@ -123,6 +159,8 @@ ArtisticColorWheelDialog::ArtisticColorWheelDialog(QWidget *parent)
m_wheel->setSaturationCount(saturationCount);
m_wheel->setValueLimit(valueLimit);
m_wheel->setValueCount(valueCount);
+ m_wheel->setGamutMaskPath(gamutMaskPath);
+ m_wheel->setGamutMaskAngle(gamutMaskAngle);
m_wheel->setColorSpace(colorSpace);
m_wheel->setColor(Qt::red);
m_wheel->setMinimumSize(64, 64);
@@ -143,6 +181,103 @@ ArtisticColorWheelDialog::ArtisticColorWheelDialog(QWidget *parent)
&ArtisticColorWheelDialog::saveSettings);
}
+void ArtisticColorWheelDialog::loadGamutMasks()
+{
+ QHash defaultGamutMaskTitles = {
+ {
+ QStringLiteral("Atmosphere with accent"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Atmosphere with accent"),
+ },
+ {
+ QStringLiteral("Atmospheric triad"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Atmospheric triad"),
+ },
+ {
+ QStringLiteral("Complementary"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Complementary"),
+ },
+ {
+ QStringLiteral("Dominant hue with accent"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Dominant hue with accent"),
+ },
+ {
+ QStringLiteral("Shifted triad"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Shifted triad"),
+ },
+ {
+ QStringLiteral("Split"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Split"),
+ },
+ {
+ QStringLiteral("Split complementary"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Split complementary"),
+ },
+ {
+ QStringLiteral("Tetradic"),
+ //: This is the name for a gamut mask, a shape on a color circle.
+ tr("Tetradic"),
+ },
+ };
+
+ QStringList datapaths = utils::paths::dataPaths();
+ QVector> entries;
+ for(const QString &path : datapaths) {
+ QDir dir(path + QStringLiteral("/gamutmasks/"));
+ for(const QString &p :
+ dir.entryList({QStringLiteral("*.svg")}, QDir::Files)) {
+ QFile f(dir.filePath(p));
+ if(!f.open(QIODevice::ReadOnly)) {
+ qWarning(
+ "Error opening gamut mask '%s': %s",
+ qUtf8Printable(f.fileName()),
+ qUtf8Printable(f.errorString()));
+ continue;
+ }
+
+ QDomDocument doc;
+ QDomDocument::ParseResult parseResult = doc.setContent(&f);
+ f.close();
+ if(!parseResult) {
+ qWarning(
+ "Error parsing gamut mask '%s': %s",
+ qUtf8Printable(f.fileName()),
+ qUtf8Printable(parseResult.errorMessage));
+ continue;
+ }
+
+ QString title;
+ QDomElement titleElement = doc.documentElement().firstChildElement(
+ QStringLiteral("title"));
+ if(!titleElement.isNull()) {
+ title = titleElement.text().trimmed();
+ }
+
+ if(title.isEmpty()) {
+ title = QFileInfo(f.fileName()).baseName();
+ }
+
+ entries.append(
+ {defaultGamutMaskTitles.value(title, title), f.fileName()});
+ }
+ }
+
+ std::sort(
+ entries.begin(), entries.end(),
+ [](const QPair &a, const QPair &b) {
+ return a.first.compare(b.first, Qt::CaseInsensitive) < 0;
+ });
+ for(const QPair &entry : entries) {
+ m_gamutMaskCombo->addItem(entry.first, entry.second);
+ }
+}
+
void ArtisticColorWheelDialog::updateContinuousHue(compat::CheckBoxState state)
{
bool limit = state == Qt::Unchecked;
@@ -187,6 +322,18 @@ void ArtisticColorWheelDialog::updateValueSteps(int steps)
m_wheel->setValueCount(steps);
}
+void ArtisticColorWheelDialog::updateGamutMask(int index)
+{
+ QString path = m_gamutMaskCombo->itemData(index).toString();
+ m_wheel->setGamutMaskPath(path);
+ m_gamutMaskAngleSlider->setEnabled(!path.isEmpty());
+}
+
+void ArtisticColorWheelDialog::updateGamutMaskAngle(int angle)
+{
+ m_wheel->setGamutMaskAngle(angle);
+}
+
void ArtisticColorWheelDialog::saveSettings()
{
desktop::settings::Settings &settings = dpApp().settings();
@@ -198,6 +345,9 @@ void ArtisticColorWheelDialog::saveSettings()
settings.setColorCircleSaturationCount(m_saturationStepsSlider->value());
settings.setColorCircleValueLimit(!m_continuousValueBox->isChecked());
settings.setColorCircleValueCount(m_valueStepsSlider->value());
+ settings.setColorCircleGamutMaskPath(
+ m_gamutMaskCombo->currentData().toString());
+ settings.setColorCircleGamutMaskAngle(m_gamutMaskAngleSlider->value());
}
}
diff --git a/src/desktop/dialogs/artisticcolorwheeldialog.h b/src/desktop/dialogs/artisticcolorwheeldialog.h
index 1564be21bc..1f8770324c 100644
--- a/src/desktop/dialogs/artisticcolorwheeldialog.h
+++ b/src/desktop/dialogs/artisticcolorwheeldialog.h
@@ -8,6 +8,7 @@
class KisSliderSpinBox;
class QCheckBox;
class QColor;
+class QComboBox;
namespace widgets {
class ArtisticColorWheel;
@@ -23,6 +24,7 @@ class ArtisticColorWheelDialog : public QDialog {
explicit ArtisticColorWheelDialog(QWidget *parent = nullptr);
private:
+ void loadGamutMasks();
void updateContinuousHue(compat::CheckBoxState state);
void updateHueSteps(int steps);
void updateHueAngle(int angle);
@@ -30,6 +32,8 @@ class ArtisticColorWheelDialog : public QDialog {
void updateSaturationSteps(int steps);
void updateContinuousValue(compat::CheckBoxState state);
void updateValueSteps(int steps);
+ void updateGamutMask(int index);
+ void updateGamutMaskAngle(int angle);
void saveSettings();
QCheckBox *m_continuousHueBox;
@@ -39,6 +43,8 @@ class ArtisticColorWheelDialog : public QDialog {
KisSliderSpinBox *m_saturationStepsSlider;
QCheckBox *m_continuousValueBox;
KisSliderSpinBox *m_valueStepsSlider;
+ QComboBox *m_gamutMaskCombo;
+ KisSliderSpinBox *m_gamutMaskAngleSlider;
widgets::ArtisticColorWheel *m_wheel;
};
diff --git a/src/desktop/docks/colorcircle.cpp b/src/desktop/docks/colorcircle.cpp
index 04d3ac7963..ea4de28afc 100644
--- a/src/desktop/docks/colorcircle.cpp
+++ b/src/desktop/docks/colorcircle.cpp
@@ -142,6 +142,10 @@ ColorCircleDock::ColorCircleDock(QWidget *parent)
m_wheel, &widgets::ArtisticColorWheel::setValueLimit);
settings.bindColorCircleValueCount(
m_wheel, &widgets::ArtisticColorWheel::setValueCount);
+ settings.bindColorCircleGamutMaskPath(
+ m_wheel, &widgets::ArtisticColorWheel::setGamutMaskPath);
+ settings.bindColorCircleGamutMaskAngle(
+ m_wheel, &widgets::ArtisticColorWheel::setGamutMaskAngle);
settings.bindColorWheelSpace(this, &ColorCircleDock::setColorSpace);
#ifdef DP_COLOR_CIRCLE_ENABLE_PREVIEW
settings.bindColorWheelPreview(this, &ColorCircleDock::setPreview);
diff --git a/src/desktop/settings_table.h b/src/desktop/settings_table.h
index aa6eb7d686..0b7a763cfb 100644
--- a/src/desktop/settings_table.h
+++ b/src/desktop/settings_table.h
@@ -96,6 +96,8 @@ SETTING(canvasShortcuts , CanvasShortcuts , "settings/canvas
#ifdef Q_OS_ANDROID
SETTING(captureVolumeRocker , CaptureVolumeRocker , "settings/android/capturevolumerocker" , true)
#endif
+SETTING(colorCircleGamutMaskAngle , ColorCircleGamutMaskAngle , "settings/colorcircle/gamutmaskangle" , 0)
+SETTING(colorCircleGamutMaskPath , ColorCircleGamutMaskPath , "settings/colorcircle/gamutmaskpath" , QString())
SETTING(colorCircleHueAngle , ColorCircleHueAngle , "settings/colorcircle/hueangle" , 0)
SETTING(colorCircleHueCount , ColorCircleHueCount , "settings/colorcircle/huecount" , 16)
SETTING(colorCircleHueLimit , ColorCircleHueLimit , "settings/colorcircle/huelimit" , false)
diff --git a/src/desktop/widgets/artisticcolorwheel.cpp b/src/desktop/widgets/artisticcolorwheel.cpp
index 23a816a5e8..7804dc8948 100644
--- a/src/desktop/widgets/artisticcolorwheel.cpp
+++ b/src/desktop/widgets/artisticcolorwheel.cpp
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#include
#include
@@ -99,6 +100,45 @@ void ArtisticColorWheel::setValueCount(int valueCount)
}
}
+void ArtisticColorWheel::setGamutMaskPath(const QString &gamutMaskPath)
+{
+ bool empty = gamutMaskPath.isEmpty();
+ if(!empty || !m_gamutMaskPath.isEmpty()) {
+ m_gamutMaskPath = gamutMaskPath;
+ delete m_gamutMask;
+
+ if(empty) {
+ m_gamutMask = nullptr;
+ } else {
+ m_gamutMask = new QSvgRenderer(gamutMaskPath);
+ if(!m_gamutMask->isValid()) {
+ qWarning(
+ "Error loading gamut mask '%s'",
+ qUtf8Printable(gamutMaskPath));
+ delete m_gamutMask;
+ m_gamutMask = nullptr;
+ }
+ }
+
+ m_wheelCache = QPixmap();
+ m_gamutMaskCache = QPixmap();
+ update();
+ }
+}
+
+void ArtisticColorWheel::setGamutMaskAngle(int gamutMaskAngle)
+{
+ qreal angle = normalizeAngle(qreal(gamutMaskAngle));
+ if(m_gamutMaskAngle != angle) {
+ m_gamutMaskAngle = angle;
+ if(m_gamutMask) {
+ m_wheelCache = QPixmap();
+ m_gamutMaskCache = QPixmap();
+ update();
+ }
+ }
+}
+
void ArtisticColorWheel::setColorSpace(ColorSpace colorSpace)
{
switch(colorSpace) {
@@ -362,6 +402,31 @@ void ArtisticColorWheel::updateWheelCache(int dimension)
painter.setPen(Qt::NoPen);
painter.setRenderHint(QPainter::Antialiasing);
painter.drawEllipse(m_wheelCache.rect());
+
+ if(m_gamutMask) {
+ updateGamutMaskCache(m_wheelCache.size());
+ painter.setCompositionMode(QPainter::CompositionMode_DestinationIn);
+ painter.setOpacity(0.5);
+ painter.drawPixmap(m_wheelCache.rect(), m_gamutMaskCache);
+ }
+ }
+}
+
+void ArtisticColorWheel::updateGamutMaskCache(const QSize &size)
+{
+ if(m_gamutMaskCache.isNull() || m_gamutMaskCache.size() != size) {
+ m_gamutMaskCache = QPixmap(size);
+ m_gamutMaskCache.fill(Qt::transparent);
+ QPainter painter(&m_gamutMaskCache);
+ if(m_gamutMaskAngle != 0.0) {
+ QTransform tf;
+ QPointF center = QRectF(m_gamutMaskCache.rect()).center();
+ tf.translate(center.x(), center.y());
+ tf.rotate(m_gamutMaskAngle);
+ tf.translate(-center.x(), -center.y());
+ painter.setTransform(tf);
+ }
+ m_gamutMask->render(&painter, m_gamutMaskCache.rect());
}
}
diff --git a/src/desktop/widgets/artisticcolorwheel.h b/src/desktop/widgets/artisticcolorwheel.h
index 1f07a95887..216110bc79 100644
--- a/src/desktop/widgets/artisticcolorwheel.h
+++ b/src/desktop/widgets/artisticcolorwheel.h
@@ -6,6 +6,8 @@
#include
#include
+class QSvgRenderer;
+
namespace widgets {
class ArtisticColorWheel : public QWidget {
@@ -31,6 +33,8 @@ class ArtisticColorWheel : public QWidget {
void setSaturationCount(int saturationCount);
void setValueLimit(bool valueLimit);
void setValueCount(int valueCount);
+ void setGamutMaskPath(const QString &path);
+ void setGamutMaskAngle(int gamutMaskAngle);
ColorSpace colorSpace() const { return m_colorSpace; }
void setColorSpace(ColorSpace colorSpace);
@@ -59,6 +63,7 @@ class ArtisticColorWheel : public QWidget {
void handleMouseOnWheel(qreal radius, qreal length, qreal angle);
void updateBarCache(const QSize &size);
void updateWheelCache(int dimension);
+ void updateGamutMaskCache(const QSize &size);
void updatePathCache(const QRectF &vr, const QRectF &wr);
qreal getHueAt(qreal angle) const;
@@ -87,9 +92,13 @@ class ArtisticColorWheel : public QWidget {
qreal m_hue = 0.0;
qreal m_saturation = 0.0;
qreal m_value = 0.0;
+ qreal m_gamutMaskAngle = 0.0;
ColorSpace m_colorSpace = ColorSpace::ColorHSV;
+ QString m_gamutMaskPath;
+ QSvgRenderer *m_gamutMask = nullptr;
QPixmap m_barCache;
QPixmap m_wheelCache;
+ QPixmap m_gamutMaskCache;
QPainterPath m_pathCache;
};