diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,8 @@ mnemonicattached.cpp wheelhandler.cpp shadowedrectangle.cpp + colorutils.cpp + pendingvalue.cpp scenegraph/shadowedrectanglenode.cpp scenegraph/shadowedrectanglematerial.cpp scenegraph/shadowedborderrectanglematerial.cpp diff --git a/src/colorutils.h b/src/colorutils.h new file mode 100644 --- /dev/null +++ b/src/colorutils.h @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include +#include + +#include "pendingvalue.h" + +/** + * Utilities for processing items to obtain colors and information useful for + * UIs that need to adjust to variable elements. + */ +class ColorUtils : public QObject +{ + Q_OBJECT +public: + /** + * Describes the contrast of an item. + */ + enum Brightness { + Dark, /**< The item is dark and requires a light foreground color to achieve readable contrast. */ + Light, /**< The item is light and requires a dark foreground color to achieve readable contrast. */ + }; + Q_ENUM(Brightness) + + explicit ColorUtils(QObject* parent = nullptr); + + /** + * Returns the average color of an image, ignoring transparent pixels. + * + * @param item The QImage to average the colours of. + * + * @param maxPixels The maximum amount of non-transparent pixels to use for + * computing the average color. Note that this is not a hard cap, as it only + * applies when the item is significantly larger than the maximum. + * + * @code{.qml} + * import QtQuick 2.0 + * import org.kde.kirigami 2.12 as Kirigami + * + * Column { + * Row { + * id: colorRow + * Rectangle { + * color: "red" + * height: 50 + * width: 50 + * } + * Rectangle { + * color: "blue" + * height: 50 + * width: 50 + * } + * } + * Rectangle { + * color: Kirigami.ColorUtils.averageColorForItem(colorRow).await() + * height: 50 + * width: 100 + * } + * } + * @endcode + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE PendingValue* averageColorForItem(QVariant *item, int maxPixels = 65536); + + /** + * Returns whether an image is bright or dark. + * + * @param item The QQuickItem to calculate the brightness of. The Item needs to have + * a non-zero size and be on a visible window. + * + * @note + * This function renders the item to an offscreen buffer and copies it from + * GPU memory to CPU memory, which can be costly. Avoid using this for items + * that are costly to render. + * + * @code{.qml} + * import QtQuick 2.0 + * import org.kde.kirigami 2.12 as Kirigami + * + * Column { + * Kirigami.Heading { + * text: { + * if (Kirigami.ColorUtils.brightnessForItem(colorRow).await() == Kirigami.ColorUtils.Light) { + * return "The item is light" + * } else { + * return "The item is dark" + * } + * } + * } + * Row { + * id: colorRow + * Rectangle { + * color: "black" + * height: 50 + * width: 50 + * } + * Rectangle { + * color: "grey" + * height: 50 + * width: 50 + * } + * } + * } + * @endcode + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE PendingValue* brightnessForItem(QVariant *item); + + /** + * Returns the result of overlaying the foreground color on the background + * color. + * + * @param foreground The color to overlay on the background. + * + * @param background The color to overlay the foreground on. + * + * @code{.qml} + * import QtQuick 2.0 + * import org.kde.kirigami 2.12 as Kirigami + * + * Rectangle { + * color: Kirigami.ColorUtils.alphaBlend(Qt.rgba(0, 0, 0, 0.5), Qt.rgba(1, 1, 1, 1)) + * } + * @endcode + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE QColor alphaBlend(QColor foreground, QColor background); + + /** + * Returns a linearly interpolated color between color one and color two. + * + * @param one The color to linearly interpolate from. + * + * @param two The color to linearly interpolate to. + * + * @param balance The balance between the two colors. 0.0 will return the + * first color, 1.0 will return the second color. Values beyond these bounds + * are valid, and will result in extrapolation. + * + * @code{.qml} + * import QtQuick 2.0 + * import org.kde.kirigami 2.12 as Kirigami + * + * Rectangle { + * color: Kirigami.ColorUtils.linearInterpolation("black", "white", 0.5) + * } + * @endcode + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE QColor linearInterpolation(QColor one, QColor two, double balance); + + /** + * Increases or decreases the properties of `color` by fixed amounts. + * + * @param color The color to adjust. + * + * @param adjustments The adjustments to apply to the color. + * + * @note `value` and `lightness` are aliases for the same value. + * + * @code{.js} + * { + * red: null, // Range: -255 to 255 + * green: null, // Range: -255 to 255 + * blue: null, // Range: -255 to 255 + * hue: null, // Range: -360 to 360 + * saturation: null, // Range: -255 to 255 + * value: null // Range: -255 to 255 + * lightness: null, // Range: -255 to 255 + * alpha: null, // Range: -255 to 255 + * } + * @endcode + * + * @warning It is an error to adjust both RGB and HSL properties. + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE QColor adjustColor(QColor color, QJSValue adjustments); + + /** + * Smoothly scales colors. + * + * @param color The color to adjust. + * + * @param adjustments The adjustments to apply to the color. Each value must + * be between `-100.0` and `100.0`. This indicates how far the property should + * be scaled from its original to the maximum if positive or to the minumum if + * negative. + * + * @note `value` and `lightness` are aliases for the same value. + * + * @code{.js} + * { + * red: null + * green: null + * blue: null + * saturation: null + * lightness: null + * value: null + * alpha: null + * } + * @endcode + * + * @warning It is an error to scale both RGB and HSL properties. + * + * @since 5.69 + * @since org.kde.kirigami 2.12 + */ + Q_INVOKABLE QColor scaleColor(QColor color, QJSValue adjustments); +}; diff --git a/src/colorutils.cpp b/src/colorutils.cpp new file mode 100644 --- /dev/null +++ b/src/colorutils.cpp @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#include "colorutils.h" + +#include +#include +#include +#include +#include + +ColorUtils::ColorUtils(QObject *parent) : QObject(parent) {} + +PendingValue* ColorUtils::averageColorForItem(QVariant *item, int maxPixels) +{ + auto pending = new PendingValue; + + auto averageImage = [maxPixels](const QImage &image) { + + // We use this lambda setup to allow a const imageData, which is easier + // for compilers to optimize due to lack of mutability. + const auto imageData = [image]() { + QList data; + // Iterate through every pixel in the list, flattening the 2D image into + // a 1D array for easier processing later on. + for (int widthIndex = 0; widthIndex < image.width(); widthIndex++) { + for (int heightIndex = 0; heightIndex < image.height(); heightIndex++) { + auto pixel = image.pixel(widthIndex, heightIndex); + // Only add pixels that aren't transparent in order to get a more + // usable color for usage in UI. + if (pixel != 0) { + data << pixel; + } + } + } + return data; + }(); + + // Compute our skip factor by dividing the amount of pixels by the maximum allowed + // amount of pixels. When amount > max, skip factor can be greater than one, causing + // pixels to be skipped for performance reasons. + const auto skipFactor = qMax(1, qFloor(imageData.length() / maxPixels)); + + int red = 0, green = 0, blue = 0, alpha = 0, colorCount = 0; + for (int pixelIndex = 0; pixelIndex < imageData.length(); pixelIndex++) { + // Since any number modulo one is zero, we don't want to attempt + // skipping when the skipFactor is one. If it's not one, we + // check if the current index modulo the skip factor is equivalent to + // zero. It'll be zero every n items, where n is the skip factor. + if ((skipFactor != 1) && (pixelIndex % skipFactor == 0)) { + continue; + } + colorCount++; + red += qRed(imageData[pixelIndex]); + green += qGreen(imageData[pixelIndex]); + blue += qBlue(imageData[pixelIndex]); + alpha += qAlpha(imageData[pixelIndex]); + } + + // If we somehow got here without any colours, we want to + // return an invalid colour instead of causing a divide by zero error. + if (colorCount == 0) { + return QColor(); + } + + // Average the colours. + red = red / colorCount; + green = green / colorCount; + blue = blue / colorCount; + alpha = alpha / colorCount; + + return QColor::fromRgb(red, green, blue, alpha); + }; + + if (item->canConvert()) { + auto casted = item->value(); + const auto response = casted->grabToImage(QSize(casted->width(), casted->height())); + if (!response) { // Response will be a null pointer when something goes wrong, + // so we return an invalid colour. + pending->setValue(QColor::Invalid); + return pending; + } + QMetaObject::Connection *const connection = new QMetaObject::Connection; + *connection = connect(response.data(), &QQuickItemGrabResult::ready, [=]() { + QObject::disconnect(*connection); + auto image = response->image(); + pending->setValue(averageImage(image)); + delete connection; + }); + } else if (item->canConvert()) { + pending->setValue(averageImage(item->value())); + } else if (item->canConvert()) { + pending->setValue(averageImage(item->value().pixmap(256, 256).toImage())); + } else if (item->canConvert()) { + pending->setValue(averageImage(QIcon::fromTheme(item->toString()).pixmap(256, 256).toImage())); + } + + return pending; +} + +PendingValue* ColorUtils::brightnessForItem(QVariant *item) { + auto result = averageColorForItem(item); + auto pending = new PendingValue; + + auto luma = [](QColor color) { + return (0.299*color.red() + 0.587*color.green() + 0.114*color.blue())/255; + }; + + if (item->canConvert() || item->canConvert() || item->canConvert()) { + pending->setValue(luma(result->value().value()) > 0.5 ? ColorUtils::Brightness::Light : ColorUtils::Brightness::Dark); + } + + connect(result, &PendingValue::ready, [=]() { + auto color = result->value().value(); + pending->setValue(luma(color) > 0.5 ? ColorUtils::Brightness::Light : ColorUtils::Brightness::Dark); + }); + + return pending; +} + +QColor ColorUtils::alphaBlend(QColor foreground, QColor background) { + const auto foregroundAlpha = foreground.alpha(); + const auto inverseForegroundAlpha = 0xff - foregroundAlpha; + const auto backgroundAlpha = background.alpha(); + + if (foregroundAlpha == 0x00) return background; + + if (backgroundAlpha == 0xff) { + return QColor::fromRgb( + (foregroundAlpha*foreground.red()) + (inverseForegroundAlpha*background.red()), + (foregroundAlpha*foreground.green()) + (inverseForegroundAlpha*background.green()), + (foregroundAlpha*foreground.blue()) + (inverseForegroundAlpha*background.blue()), + 0xff + ); + } else { + const auto inverseBackgroundAlpha = (backgroundAlpha * inverseForegroundAlpha) / 255; + const auto finalAlpha = foregroundAlpha + inverseBackgroundAlpha; + Q_ASSERT(finalAlpha != 0x00); + return QColor::fromRgb( + (foregroundAlpha*foreground.red()) + (inverseBackgroundAlpha*background.red()), + (foregroundAlpha*foreground.green()) + (inverseBackgroundAlpha*background.green()), + (foregroundAlpha*foreground.blue()) + (inverseBackgroundAlpha*background.blue()), + finalAlpha + ); + } +} + +QColor ColorUtils::linearInterpolation(QColor one, QColor two, double balance) { + + auto scaleAlpha = [](QColor color, double factor) { + return QColor::fromRgb(color.red(), color.green(), color.blue(), color.alpha()*factor); + }; + auto linearlyInterpolateDouble = [](double one, double two, double factor) { + return one + (two - one) * factor; + }; + + if (one == Qt::transparent) return scaleAlpha(two, balance); + if (two == Qt::transparent) return scaleAlpha(one, 1 - balance); + + return QColor::fromHsv( + std::fmod(linearlyInterpolateDouble(one.hue(), two.hue(), balance), 360.0), + qBound(0.0, linearlyInterpolateDouble(one.saturation(), two.saturation(), balance), 255.0), + qBound(0.0, linearlyInterpolateDouble(one.value(), two.value(), balance), 255.0), + qBound(0.0, linearlyInterpolateDouble(one.alpha(), two.alpha(), balance), 255.0) + ); +} + +// Some private things for the adjust, change, and scale properties +struct ParsedAdjustments +{ + double red = 0.0; + double green = 0.0; + double blue = 0.0; + + double hue = 0.0; + double saturation = 0.0; + double value = 0.0; + + double alpha = 0.0; +}; + +ParsedAdjustments parseAdjustments(QJSValue value) +{ + ParsedAdjustments parsed; + + auto checkProperty = [](QJSValue value, QString property) { + if (value.hasProperty(property)) { + auto val = value.property(property); + if (val.isNumber()) { + return QVariant::fromValue(val.toNumber()); + } + } + return QVariant(); + }; + + std::map map = { + { QStringLiteral("red"), parsed.red }, + { QStringLiteral("green"), parsed.green }, + { QStringLiteral("blue"), parsed.blue }, + // + { QStringLiteral("hue"), parsed.hue }, + { QStringLiteral("saturation"), parsed.saturation }, + { QStringLiteral("value"), parsed.value }, + { QStringLiteral("lightness"), parsed.value }, + // + { QStringLiteral("alpha"), parsed.alpha } + }; + + for (std::pair item : map) { + auto val = checkProperty(value, item.first); + if (val != QVariant()) item.second = val.toDouble(); + } + + if ((parsed.red || parsed.green || parsed.blue) && (parsed.hue || parsed.saturation || parsed.value)) { + qCritical() << "It is an error to have both RGB and HSL values in an adjustment."; + } + + return parsed; +} + +QColor ColorUtils::adjustColor(QColor color, QJSValue adjustments) +{ + auto adjusts = parseAdjustments(adjustments); + + if (qBound(-360.0, adjusts.hue, 360.0) != adjusts.hue) qCritical() << "Hue is out of bounds"; + + if (qBound(-255.0, adjusts.red, 255.0) != adjusts.red) qCritical() << "Red is out of bounds"; + if (qBound(-255.0, adjusts.green, 255.0) != adjusts.green) qCritical() << "Green is out of bounds"; + if (qBound(-255.0, adjusts.blue, 255.0) != adjusts.blue) qCritical() << "Green is out of bounds"; + if (qBound(-255.0, adjusts.saturation, 255.0) != adjusts.saturation) qCritical() << "Saturation is out of bounds"; + if (qBound(-255.0, adjusts.value, 255.0) != adjusts.value) qCritical() << "Value is out of bounds"; + if (qBound(-255.0, adjusts.alpha, 255.0) != adjusts.alpha) qCritical() << "Alpha is out of bounds"; + + auto copy = color; + + if (adjusts.alpha) { + copy.setAlpha(adjusts.alpha); + } + + if (adjusts.red || adjusts.green || adjusts.blue) { + copy.setRed(copy.red() + adjusts.red); + copy.setGreen(copy.green() + adjusts.green); + copy.setBlue(copy.blue() + adjusts.blue); + } else if (adjusts.hue || adjusts.saturation || adjusts.value) { + copy.setHsl( + std::fmod(copy.hue()+adjusts.hue, 360.0), + copy.saturation()+adjusts.saturation, + copy.value()+adjusts.value, + copy.alpha() + ); + } + + return copy; +} + +QColor ColorUtils::scaleColor(QColor color, QJSValue adjustments) +{ + auto adjusts = parseAdjustments(adjustments); + auto copy = color; + + if (qBound(-100.0, adjusts.red, 100.00) != adjusts.red) qCritical() << "Red is out of bounds"; + if (qBound(-100.0, adjusts.green, 100.00) != adjusts.green) qCritical() << "Green is out of bounds"; + if (qBound(-100.0, adjusts.blue, 100.00) != adjusts.blue) qCritical() << "Blue is out of bounds"; + if (qBound(-100.0, adjusts.saturation, 100.00) != adjusts.saturation) qCritical() << "Saturation is out of bounds"; + if (qBound(-100.0, adjusts.value, 100.00) != adjusts.value) qCritical() << "Value is out of bounds"; + if (qBound(-100.0, adjusts.alpha, 100.00) != adjusts.alpha) qCritical() << "Alpha is out of bounds"; + + if (adjusts.hue != 0) qCritical() << "Hue cannot be scaled"; + + auto shiftToAverage = [](double current, double factor) { + auto scale = qBound(-100.0, factor, 100.0)/100; + return current + (scale > 0 ? 255 - current : current) * scale; + }; + + if (adjusts.red || adjusts.green || adjusts.blue) { + copy.setRed(qBound(0.0, shiftToAverage(copy.red(), adjusts.red), 255.0)); + copy.setGreen(qBound(0.0, shiftToAverage(copy.green(), adjusts.green), 255.0)); + copy.setBlue(qBound(0.0, shiftToAverage(copy.blue(), adjusts.blue), 255.0)); + } else { + copy.setHsl( + copy.hue(), + qBound(0.0, shiftToAverage(copy.saturation(), adjusts.saturation), 255.0), + qBound(0.0, shiftToAverage(copy.value(), adjusts.value), 255.0), + qBound(0.0, shiftToAverage(copy.alpha(), adjusts.alpha), 255.0) + ); + } + + return copy; +} \ No newline at end of file diff --git a/src/kirigamiplugin.cpp b/src/kirigamiplugin.cpp --- a/src/kirigamiplugin.cpp +++ b/src/kirigamiplugin.cpp @@ -18,6 +18,7 @@ #include "scenepositionattached.h" #include "wheelhandler.h" #include "shadowedrectangle.h" +#include "colorutils.h" #include #include @@ -240,6 +241,8 @@ qmlRegisterType(uri, 2, 12, "ShadowedRectangle"); qmlRegisterUncreatableType(uri, 2, 12, "BorderGroup", QStringLiteral("Used as grouped property")); qmlRegisterUncreatableType(uri, 2, 12, "ShadowGroup", QStringLiteral("Used as grouped property")); + qmlRegisterSingletonType(uri, 2, 12, "ColorUtils", [](QQmlEngine*, QJSEngine*) { return new ColorUtils; }); + qmlRegisterUncreatableType(uri, 2, 12, "PendingValue", QStringLiteral("Cannot create objects of type PendingValue.")); qmlProtectModule(uri, 2); } diff --git a/src/pendingvalue.h b/src/pendingvalue.h new file mode 100644 --- /dev/null +++ b/src/pendingvalue.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#pragma once + +#include +#include + +/** + * Represents a QVariant that may or may not exist yet. + */ +class PendingValue : public QObject +{ + Q_OBJECT + Q_PROPERTY(QVariant value READ value WRITE setValue NOTIFY ready) + +public: + explicit PendingValue(QObject *parent = nullptr); + /** + * Convenience function for synchronously waiting on the QVariant to exist. + */ + Q_INVOKABLE QVariant await(); + + QVariant value(); + void setValue(QVariant value); + +Q_SIGNALS: + /** + * Emitted when the QVariant is ready for usage. + */ + void ready(QVariant); + +private: + QVariant m_value; + +}; \ No newline at end of file diff --git a/src/pendingvalue.cpp b/src/pendingvalue.cpp new file mode 100644 --- /dev/null +++ b/src/pendingvalue.cpp @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#include "pendingvalue.h" + +#include + +PendingValue::PendingValue(QObject *parent) : QObject(parent) {} + +QVariant PendingValue::await() +{ + if (!m_value.isNull()) return m_value; + + QEventLoop loop; + connect(this, &PendingValue::ready, &loop, &QEventLoop::quit); + loop.exec(); + + return m_value; +} + +QVariant PendingValue::value() +{ + return m_value; +} + +void PendingValue::setValue(QVariant value) +{ + m_value = value; + Q_EMIT ready(value); +} \ No newline at end of file