File indexing completed on 2024-05-12 04:44:29

0001 // SPDX-FileCopyrightText: Lukas Sommer <sommerluk@gmail.com>
0002 // SPDX-License-Identifier: BSD-2-Clause OR MIT
0003 
0004 // Own headers
0005 // First the interface, which forces the header to be self-contained.
0006 #include "chromalightnessdiagram.h"
0007 // Second, the private implementation.
0008 #include "chromalightnessdiagram_p.h" // IWYU pragma: associated
0009 
0010 #include "abstractdiagram.h"
0011 #include "cielchd50values.h"
0012 #include "constpropagatingrawpointer.h"
0013 #include "constpropagatinguniquepointer.h"
0014 #include "helperconstants.h"
0015 #include "lchdouble.h"
0016 #include "rgbcolorspace.h"
0017 #include <optional>
0018 #include <qcolor.h>
0019 #include <qevent.h>
0020 #include <qimage.h>
0021 #include <qlist.h>
0022 #include <qmath.h>
0023 #include <qnamespace.h>
0024 #include <qpainter.h>
0025 #include <qpen.h>
0026 #include <qpoint.h>
0027 #include <qrect.h>
0028 #include <qrgb.h>
0029 #include <qsharedpointer.h>
0030 #include <qsizepolicy.h>
0031 #include <qwidget.h>
0032 #include <type_traits>
0033 #include <utility>
0034 
0035 namespace PerceptualColor
0036 {
0037 /** @brief The constructor.
0038  *
0039  * @param colorSpace The color space within which the widget should operate.
0040  * Can be created with @ref RgbColorSpaceFactory.
0041  *
0042  * @param parent Passed to the QWidget base class constructor */
0043 ChromaLightnessDiagram::ChromaLightnessDiagram(const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace, QWidget *parent)
0044     : AbstractDiagram(parent)
0045     , d_pointer(new ChromaLightnessDiagramPrivate(this))
0046 {
0047     // Setup the color space must be the first thing to do because
0048     // other operations rely on a working color space.
0049     d_pointer->m_rgbColorSpace = colorSpace;
0050 
0051     // Initialization
0052     setFocusPolicy(Qt::FocusPolicy::StrongFocus);
0053     setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
0054     d_pointer->m_chromaLightnessImageParameters.imageSizePhysical = //
0055         d_pointer->calculateImageSizePhysical();
0056     d_pointer->m_chromaLightnessImageParameters.rgbColorSpace = colorSpace;
0057     d_pointer->m_chromaLightnessImage.setImageParameters( //
0058         d_pointer->m_chromaLightnessImageParameters);
0059 
0060     // Connections
0061     connect(&d_pointer->m_chromaLightnessImage, //
0062             &AsyncImageProvider<ChromaLightnessImageParameters>::interlacingPassCompleted, //
0063             this, //
0064             &ChromaLightnessDiagram::callUpdate);
0065 }
0066 
0067 /** @brief Default destructor */
0068 ChromaLightnessDiagram::~ChromaLightnessDiagram() noexcept
0069 {
0070 }
0071 
0072 /** @brief Constructor
0073  *
0074  * @param backLink Pointer to the object from which <em>this</em> object
0075  * is the private implementation. */
0076 ChromaLightnessDiagramPrivate::ChromaLightnessDiagramPrivate(ChromaLightnessDiagram *backLink)
0077     : m_currentColor(CielchD50Values::srgbVersatileInitialColor)
0078     , q_pointer(backLink)
0079 {
0080 }
0081 
0082 /** @brief Updates @ref ChromaLightnessDiagram::currentColor corresponding
0083  * to the given widget pixel position.
0084  *
0085  * @param widgetPixelPosition The position of a pixel within the widget’s
0086  * coordinate system. This does not necessarily need to intersect with the
0087  * actually displayed diagram or the gamut. It might even be negative or
0088  * outside the widget.
0089  *
0090  * @post If the pixel position is within the gamut, then the corresponding
0091  * @ref ChromaLightnessDiagram::currentColor is set. If the pixel position
0092  * is outside the gamut, than a nearby in-gamut color is set (hue is
0093  * preserved, chroma and lightness are adjusted). Exception: If the
0094  * widget is so small that no diagram is displayed, nothing will happen. */
0095 void ChromaLightnessDiagramPrivate::setCurrentColorFromWidgetPixelPosition(const QPoint widgetPixelPosition)
0096 {
0097     const LchDouble color = fromWidgetPixelPositionToColor(widgetPixelPosition);
0098     q_pointer->setCurrentColor(
0099         // Search for the nearest color without changing the hue:
0100         nearestInGamutColorByAdjustingChromaLightness(color.c, color.l));
0101 }
0102 
0103 /** @brief The border between the widget outer top, right and bottom
0104  * border and the diagram itself.
0105  *
0106  * @returns The border between the widget outer top, right and bottom
0107  * border and the diagram itself.
0108  *
0109  * The diagram is not painted on the whole extend of the widget.
0110  * A border is left to allow that the selection handle can be painted
0111  * completely even when a pixel on the border of the diagram is
0112  * selected.
0113  *
0114  * This is the value for the top, right and bottom border. For the left
0115  * border, see @ref leftBorderPhysical() instead.
0116  *
0117  * Measured in <em>physical pixels</em>. */
0118 int ChromaLightnessDiagramPrivate::defaultBorderPhysical() const
0119 {
0120     const qreal border = q_pointer->handleRadius() //
0121         + q_pointer->handleOutlineThickness() / 2.0;
0122     return qCeil(border * q_pointer->devicePixelRatioF());
0123 }
0124 
0125 /** @brief The left border between the widget outer left border and the
0126  * diagram itself.
0127  *
0128  * @returns The left border between the widget outer left border and the
0129  * diagram itself.
0130  *
0131  * The diagram is not painted on the whole extend of the widget.
0132  * A border is left to allow that the selection handle can be painted
0133  * completely even when a pixel on the border of the diagram is
0134  * selected. Also, there is space left for the focus indicator.
0135  *
0136  * This is the value for the left border. For the other three borders,
0137  * see @ref defaultBorderPhysical() instead.
0138  *
0139  * Measured in <em>physical pixels</em>. */
0140 int ChromaLightnessDiagramPrivate::leftBorderPhysical() const
0141 {
0142     const int focusIndicatorThickness = qCeil( //
0143         q_pointer->handleOutlineThickness() * q_pointer->devicePixelRatioF());
0144 
0145     // Candidate 1:
0146     const int candidateOne = defaultBorderPhysical() + focusIndicatorThickness;
0147 
0148     // Candidate 2: Generally recommended value for focus indicator:
0149     const int candidateTwo = qCeil( //
0150         q_pointer->spaceForFocusIndicator() * q_pointer->devicePixelRatioF());
0151 
0152     return qMax(candidateOne, candidateTwo);
0153 }
0154 
0155 /** @brief Calculate a size for @ref m_chromaLightnessImage that corresponds
0156  * to the current widget size.
0157  *
0158  * @returns The size for @ref m_chromaLightnessImage that corresponds
0159  * to the current widget size. Measured in <em>physical pixels</em>. */
0160 QSize ChromaLightnessDiagramPrivate::calculateImageSizePhysical() const
0161 {
0162     const QSize borderSizePhysical(
0163         // Borders:
0164         leftBorderPhysical() + defaultBorderPhysical(), // left + right
0165         2 * defaultBorderPhysical() // top + bottom
0166     );
0167     return q_pointer->physicalPixelSize() - borderSizePhysical;
0168 }
0169 
0170 /** @brief Converts widget pixel positions to color.
0171  *
0172  * @param widgetPixelPosition The position of a pixel of the widget
0173  * coordinate system. The given value  does not necessarily need to
0174  * be within the actual displayed widget. It might even be negative.
0175  *
0176  * @returns The corresponding color for the (center of the) given
0177  * widget pixel position. (The value is not normalized. It might have
0178  * a negative C value if the position is on the left of the diagram,
0179  * or an L value smaller than 0 or bigger than 100…) Exception: If
0180  * the widget is too small to show a diagram, a default color is
0181  * returned.
0182  *
0183  * @sa @ref measurementdetails */
0184 LchDouble ChromaLightnessDiagramPrivate::fromWidgetPixelPositionToColor(const QPoint widgetPixelPosition) const
0185 {
0186     const QPointF offset(leftBorderPhysical(), defaultBorderPhysical());
0187     const QPointF imageCoordinatePoint = widgetPixelPosition
0188         // Offset to pass from widget reference system
0189         // to image reference system:
0190         - offset / q_pointer->devicePixelRatioF()
0191         // Offset to pass from pixel positions to coordinate points:
0192         + QPointF(0.5, 0.5);
0193     LchDouble color;
0194     color.h = m_currentColor.h;
0195     const qreal diagramHeight = //
0196         calculateImageSizePhysical().height() / q_pointer->devicePixelRatioF();
0197     if (diagramHeight > 0) {
0198         color.l = imageCoordinatePoint.y() * 100.0 / diagramHeight * (-1.0) + 100.0;
0199         color.c = imageCoordinatePoint.x() * 100.0 / diagramHeight;
0200     } else {
0201         color.l = 50;
0202         color.c = 0;
0203     }
0204     return color;
0205 }
0206 
0207 /** @brief React on a mouse press event.
0208  *
0209  * Reimplemented from base class.
0210  *
0211  * Does not differentiate between left, middle and right mouse click.
0212  *
0213  * If the mouse moves inside the <em>displayed</em> gamut, the handle
0214  * is displaced there. If the mouse moves outside the <em>displayed</em>
0215  * gamut, the handle is displaced to a nearby in-gamut color.
0216  *
0217  * @param event The corresponding mouse event
0218  *
0219  * @internal
0220  *
0221  * @todo This widget reacts on mouse press events also when they occur
0222  * within the border. It might be nice if it would not. On the other
0223  * hand: The border is small. Would it really be worth the pain to
0224  * implement this? */
0225 void ChromaLightnessDiagram::mousePressEvent(QMouseEvent *event)
0226 {
0227     d_pointer->m_isMouseEventActive = true;
0228     d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
0229     if (d_pointer->isWidgetPixelPositionInGamut(event->pos())) {
0230         setCursor(Qt::BlankCursor);
0231     } else {
0232         unsetCursor();
0233     }
0234 }
0235 
0236 /** @brief React on a mouse move event.
0237  *
0238  * Reimplemented from base class.
0239  *
0240  * If the mouse moves inside the <em>displayed</em> gamut, the handle
0241  * is displaced there. If the mouse moves outside the <em>displayed</em>
0242  * gamut, the handle is displaced to a nearby in-gamut color.
0243  *
0244  * @param event The corresponding mouse event */
0245 void ChromaLightnessDiagram::mouseMoveEvent(QMouseEvent *event)
0246 {
0247     d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
0248     if (d_pointer->isWidgetPixelPositionInGamut(event->pos())) {
0249         setCursor(Qt::BlankCursor);
0250     } else {
0251         unsetCursor();
0252     }
0253 }
0254 
0255 /** @brief React on a mouse release event.
0256  *
0257  * Reimplemented from base class. Does not differentiate between left,
0258  * middle and right mouse click.
0259  *
0260  * If the mouse moves inside the <em>displayed</em> gamut, the handle
0261  * is displaced there. If the mouse moves outside the <em>displayed</em>
0262  * gamut, the handle is displaced to a nearby in-gamut color.
0263  *
0264  * @param event The corresponding mouse event */
0265 void ChromaLightnessDiagram::mouseReleaseEvent(QMouseEvent *event)
0266 {
0267     d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
0268     unsetCursor();
0269 }
0270 
0271 /** @brief Paint the widget.
0272  *
0273  * Reimplemented from base class.
0274  *
0275  * @param event the paint event */
0276 void ChromaLightnessDiagram::paintEvent(QPaintEvent *event)
0277 {
0278     Q_UNUSED(event)
0279     // We do not paint directly on the widget, but on a QImage buffer first:
0280     // Render anti-aliased looks better. But as Qt documentation says:
0281     //
0282     //      “Renderhints are used to specify flags to QPainter that may or
0283     //       may not be respected by any given engine.”
0284     //
0285     // Painting here directly on the widget might lead to different
0286     // anti-aliasing results depending on the underlying window system. This
0287     // is especially problematic as anti-aliasing might shift or not a pixel
0288     // to the left or to the right. So we paint on a QImage first. As QImage
0289     // (at difference to QPixmap and a QWidget) is independent of native
0290     // platform rendering, it guarantees identical anti-aliasing results on
0291     // all platforms. Here the quote from QPainter class documentation:
0292     //
0293     //      “To get the optimal rendering result using QPainter, you should
0294     //       use the platform independent QImage as paint device; i.e. using
0295     //       QImage will ensure that the result has an identical pixel
0296     //       representation on any platform.”
0297     QImage paintBuffer(physicalPixelSize(), //
0298                        QImage::Format_ARGB32_Premultiplied);
0299     paintBuffer.fill(Qt::transparent);
0300     QPainter painter(&paintBuffer);
0301     QPen pen;
0302     painter.setRenderHint(QPainter::Antialiasing, false);
0303 
0304     // Paint the diagram itself.
0305     // Request image update. If the cache is not up-to-date, this
0306     // will trigger a new paint event, once the cache has been updated.
0307     d_pointer->m_chromaLightnessImage.refreshAsync();
0308     const QColor myNeutralGray = //
0309         d_pointer->m_rgbColorSpace->fromCielchD50ToQRgbBound(CielchD50Values::neutralGray);
0310     painter.setPen(Qt::NoPen);
0311     painter.setBrush(myNeutralGray);
0312     const auto imageSize = //
0313         d_pointer->m_chromaLightnessImage.imageParameters().imageSizePhysical;
0314     painter.drawRect( // Paint diagram background
0315                       // Operating in physical pixels:
0316         d_pointer->leftBorderPhysical(), // x position (top-left)
0317         d_pointer->defaultBorderPhysical(), // y position (top-left));
0318         imageSize.width(),
0319         imageSize.height());
0320     painter.drawImage( // Paint the diagram itself as available in the cache.
0321                        // Operating in physical pixels:
0322         d_pointer->leftBorderPhysical(), // x position (top-left)
0323         d_pointer->defaultBorderPhysical(), // y position (top-left)
0324         d_pointer->m_chromaLightnessImage.getCache() // image
0325     );
0326 
0327     // Paint a focus indicator.
0328     //
0329     // We could paint a focus indicator (round or rectangular) around the
0330     // handle. Depending on the currently selected hue for the diagram,
0331     // it looks ugly because the colors of focus indicator and diagram
0332     // do not harmonize, or it is mostly invisible the the colors are
0333     // similar. So this approach does not work well.
0334     //
0335     // It seems better to paint a focus indicator for the whole widget.
0336     // We could use the style primitives to paint a rectangular focus
0337     // indicator around the whole widget:
0338     //
0339     // style()->drawPrimitive(
0340     //     QStyle::PE_FrameFocusRect,
0341     //     &option,
0342     //     &painter,
0343     //     this
0344     // );
0345     //
0346     // However, this does not work well because the chroma-lightness
0347     // diagram has usually a triangular shape. The style primitive, however,
0348     // often paints just a line at the bottom of the widget. That does not
0349     // look good. An alternative approach is that we paint ourselves a focus
0350     // indicator only on the left of the diagram (which is the place of
0351     // black/gray/white, so the won't be any problems with non-harmonic
0352     // colors).
0353     //
0354     // Then we have to design the line that we want to display. It is better
0355     // to do that ourselves instead of relying on generic QStyle::PE_Frame or
0356     // similar solutions as their result seems to be quite unpredictable across
0357     // various styles. So we use handleOutlineThickness as line width and
0358     // paint it at the left-most possible position.
0359     if (hasFocus()) {
0360         pen = QPen();
0361         pen.setWidthF(handleOutlineThickness() * devicePixelRatioF());
0362         pen.setColor(focusIndicatorColor());
0363         pen.setCapStyle(Qt::PenCapStyle::FlatCap);
0364         painter.setPen(pen);
0365         painter.setRenderHint(QPainter::Antialiasing, true);
0366         const QPointF pointOne(
0367             // x:
0368             handleOutlineThickness() * devicePixelRatioF() / 2.0,
0369             // y:
0370             0 + d_pointer->defaultBorderPhysical());
0371         const QPointF pointTwo(
0372             // x:
0373             handleOutlineThickness() * devicePixelRatioF() / 2.0,
0374             // y:
0375             physicalPixelSize().height() - d_pointer->defaultBorderPhysical());
0376         painter.drawLine(pointOne, pointTwo);
0377     }
0378 
0379     // Paint the handle on-the-fly.
0380     const int diagramHeight = d_pointer->calculateImageSizePhysical().height();
0381     QPointF colorCoordinatePoint = QPointF(
0382         // x:
0383         d_pointer->m_currentColor.c * diagramHeight / 100.0,
0384         // y:
0385         d_pointer->m_currentColor.l * diagramHeight / 100.0 * (-1) + diagramHeight);
0386     colorCoordinatePoint += QPointF(
0387         // horizontal offset:
0388         d_pointer->leftBorderPhysical(),
0389         // vertical offset:
0390         d_pointer->defaultBorderPhysical());
0391     pen = QPen();
0392     pen.setWidthF(handleOutlineThickness() * devicePixelRatioF());
0393     pen.setColor(handleColorFromBackgroundLightness(d_pointer->m_currentColor.l));
0394     painter.setPen(pen);
0395     painter.setBrush(Qt::NoBrush);
0396     painter.setRenderHint(QPainter::Antialiasing, true);
0397     painter.drawEllipse(colorCoordinatePoint, // center
0398                         handleRadius() * devicePixelRatioF(), // x radius
0399                         handleRadius() * devicePixelRatioF() // y radius
0400     );
0401 
0402     // Paint the buffer to the actual widget
0403     paintBuffer.setDevicePixelRatio(devicePixelRatioF());
0404     QPainter widgetPainter(this);
0405     widgetPainter.setRenderHint(QPainter::Antialiasing, true);
0406     widgetPainter.drawImage(0, 0, paintBuffer);
0407 }
0408 
0409 /** @brief React on key press events.
0410  *
0411  * Reimplemented from base class.
0412  *
0413  *  When the arrow keys are pressed, it moves the
0414  * handle a small step into the desired direction.
0415  * When <tt>Qt::Key_PageUp</tt>, <tt>Qt::Key_PageDown</tt>,
0416  * <tt>Qt::Key_Home</tt> or <tt>Qt::Key_End</tt> are pressed, it moves the
0417  * handle a big step into the desired direction.
0418  *
0419  * Other key events are forwarded to the base class.
0420  *
0421  * @param event the event
0422  *
0423  * @internal
0424  *
0425  * @todo Is the current behaviour (when pressing right arrow while yet
0426  * at the right border of the gamut, also the lightness is adjusted to
0427  * allow moving actually to the right) really a good idea? Anyway, it
0428  * has a bug, and arrow-down does not work on blue hues because the
0429  * gamut has some sort of corner, and there, the curser blocks. */
0430 void ChromaLightnessDiagram::keyPressEvent(QKeyEvent *event)
0431 {
0432     LchDouble temp = d_pointer->m_currentColor;
0433     switch (event->key()) {
0434     case Qt::Key_Up:
0435         temp.l += singleStepLightness;
0436         break;
0437     case Qt::Key_Down:
0438         temp.l -= singleStepLightness;
0439         break;
0440     case Qt::Key_Left:
0441         temp.c = qMax<double>(0, temp.c - singleStepChroma);
0442         break;
0443     case Qt::Key_Right:
0444         temp.c += singleStepChroma;
0445         temp = d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp);
0446         break;
0447     case Qt::Key_PageUp:
0448         temp.l += pageStepLightness;
0449         break;
0450     case Qt::Key_PageDown:
0451         temp.l -= pageStepLightness;
0452         break;
0453     case Qt::Key_Home:
0454         temp.c += pageStepChroma;
0455         temp = d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp);
0456         break;
0457     case Qt::Key_End:
0458         temp.c = qMax<double>(0, temp.c - pageStepChroma);
0459         break;
0460     default:
0461         // Quote from Qt documentation:
0462         //
0463         //     “If you reimplement this handler, it is very important that
0464         //      you call the base class implementation if you do not act
0465         //      upon the key.
0466         //
0467         //      The default implementation closes popup widgets if the
0468         //      user presses the key sequence for QKeySequence::Cancel
0469         //      (typically the Escape key). Otherwise the event is
0470         //      ignored, so that the widget’s parent can interpret it.“
0471         QWidget::keyPressEvent(event);
0472         return;
0473     }
0474     // Here we reach only if the key has been recognized. If not, in the
0475     // default branch of the switch statement, we would have passed the
0476     // keyPressEvent yet to the parent and returned.
0477 
0478     // Set the new color (only takes effect when the color is indeed different).
0479     setCurrentColor(
0480         // Search for the nearest color without changing the hue:
0481         d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp));
0482     // TODO Instead of this, simply do setCurrentColor(temp); but guarantee
0483     // for up, down, page-up and page-down that the lightness is raised
0484     // or reduced until fitting into the gamut. Maybe find a way to share
0485     // code with reduceChromaToFitIntoGamut ?
0486 }
0487 
0488 /** @brief Tests if a given widget pixel position is within
0489  * the <em>displayed</em> gamut.
0490  *
0491  * @param widgetPixelPosition The position of a pixel of the widget coordinate
0492  * system. The given value  does not necessarily need to be within the
0493  * actual displayed diagram or even the gamut itself. It might even be
0494  * negative.
0495  *
0496  * @returns <tt>true</tt> if the widget pixel position is within the
0497  * <em>currently displayed gamut</em>. Otherwise <tt>false</tt>.
0498  *
0499  * @internal
0500  *
0501  * @todo How does isInGamut() react? Does it also control valid chroma
0502  * and lightness ranges? */
0503 bool ChromaLightnessDiagramPrivate::isWidgetPixelPositionInGamut(const QPoint widgetPixelPosition) const
0504 {
0505     if (calculateImageSizePhysical().isEmpty()) {
0506         // If there is no displayed gamut, the answer must be false.
0507         // But fromWidgetPixelPositionToColor() would return an in-gamut
0508         // fallback color nevertheless. Therefore, we have to catch
0509         // the special case with an empty diagram here manually.
0510         return false;
0511     }
0512 
0513     const LchDouble color = fromWidgetPixelPositionToColor(widgetPixelPosition);
0514 
0515     // Test if C is in range. This is important because a negative C value
0516     // can be in-gamut, but is not in the _displayed_ gamut.
0517     if (color.c < 0) {
0518         return false;
0519     }
0520 
0521     // Actually for in-gamut color:
0522     return m_rgbColorSpace->isCielchD50InGamut(color);
0523 }
0524 
0525 /** @brief Setter for the @ref currentColor() property.
0526  *
0527  * @param newCurrentColor the new @ref currentColor
0528  *
0529  * @todo When an out-of-gamut color is given, both lightness and chroma
0530  * are adjusted. But does this really make sense? In @ref WheelColorPicker,
0531  * when using the hue wheel, also <em>both</em>, lightness <em>and</em> chroma
0532  * will change. Isn’t that confusing? */
0533 void ChromaLightnessDiagram::setCurrentColor(const PerceptualColor::LchDouble &newCurrentColor)
0534 {
0535     if (newCurrentColor.hasSameCoordinates(d_pointer->m_currentColor)) {
0536         return;
0537     }
0538 
0539     double oldHue = d_pointer->m_currentColor.h;
0540     d_pointer->m_currentColor = newCurrentColor;
0541     if (d_pointer->m_currentColor.h != oldHue) {
0542         // Update the diagram (only if the hue has changed):
0543         d_pointer->m_chromaLightnessImageParameters.hue = //
0544             d_pointer->m_currentColor.h;
0545         d_pointer->m_chromaLightnessImage.setImageParameters( //
0546             d_pointer->m_chromaLightnessImageParameters);
0547     }
0548     update(); // Schedule a paint event
0549     Q_EMIT currentColorChanged(newCurrentColor);
0550 }
0551 
0552 /** @brief React on a resize event.
0553  *
0554  * Reimplemented from base class.
0555  *
0556  * @param event The corresponding event */
0557 void ChromaLightnessDiagram::resizeEvent(QResizeEvent *event)
0558 {
0559     Q_UNUSED(event)
0560     d_pointer->m_chromaLightnessImageParameters.imageSizePhysical = //
0561         d_pointer->calculateImageSizePhysical();
0562     d_pointer->m_chromaLightnessImage.setImageParameters( //
0563         d_pointer->m_chromaLightnessImageParameters);
0564     // As by Qt documentation:
0565     //     “The widget will be erased and receive a paint event
0566     //      immediately after processing the resize event. No drawing
0567     //      need be (or should be) done inside this handler.”
0568 }
0569 
0570 /** @brief Recommended size for the widget.
0571  *
0572  * Reimplemented from base class.
0573  *
0574  * @returns Recommended size for the widget.
0575  *
0576  * @sa @ref minimumSizeHint() */
0577 QSize ChromaLightnessDiagram::sizeHint() const
0578 {
0579     return minimumSizeHint() * scaleFromMinumumSizeHintToSizeHint;
0580 }
0581 
0582 /** @brief Recommended minimum size for the widget
0583  *
0584  * Reimplemented from base class.
0585  *
0586  * @returns Recommended minimum size for the widget.
0587  *
0588  * @sa @ref sizeHint() */
0589 QSize ChromaLightnessDiagram::minimumSizeHint() const
0590 {
0591     const int minimumHeight = qRound(
0592         // Top border and bottom border:
0593         2.0 * d_pointer->defaultBorderPhysical() / devicePixelRatioF()
0594         // Add the height for the diagram:
0595         + gradientMinimumLength());
0596     const int minimumWidth = qRound(
0597         // Left border and right border:
0598         (d_pointer->leftBorderPhysical() + d_pointer->defaultBorderPhysical()) / devicePixelRatioF()
0599         // Add the gradient minimum length from y axis, multiplied with
0600         // the factor to allow at correct scaling showing up the whole
0601         // chroma range of the gamut.
0602         + gradientMinimumLength() * d_pointer->m_rgbColorSpace->profileMaximumCielchD50Chroma() / 100.0);
0603     // Expand to the global minimum size for GUI elements
0604     return QSize(minimumWidth, minimumHeight);
0605 }
0606 
0607 // No documentation here (documentation of properties
0608 // and its getters are in the header)
0609 LchDouble PerceptualColor::ChromaLightnessDiagram::currentColor() const
0610 {
0611     return d_pointer->m_currentColor;
0612 }
0613 
0614 /** @brief An abstract Nearest-neighbor-search algorithm.
0615  *
0616  * There are many different solutions for
0617  * <a href="https://en.wikipedia.org/wiki/Nearest_neighbor_search">
0618  * Nearest-neighbor-searches</a>. This one is not naive, but still quite easy
0619  * to implement. It is based on <a href="https://stackoverflow.com/a/307523">
0620  * this Stackoverflow answer</a>.
0621  *
0622  * @param point The point to which the nearest neighbor is searched.
0623  * @param searchRectangle The rectangle within which the algorithm searches
0624  *        for a nearest neighbor. All points outside this rectangle are
0625  *        ignored.
0626  * @param doesPointExist A callback function that must return <tt>true</tt>
0627  *        for points that are considered to exist, and <tt>false</tt> for
0628  *        points that are considered to no exist. This callback function will
0629  *        never be called with points outside the search rectangle.
0630  * @returns The nearest neighbor, if any. An empty value otherwise. If there
0631  *          are multiple non-transparent pixels at the same distance, it is
0632  *          indeterminate which one is returned.  Note that the point itself is
0633  *          considered to be itself its nearest neighbor if it is within the
0634  *          search rectangle and considered by the test function to exist. */
0635 std::optional<QPoint>
0636 ChromaLightnessDiagramPrivate::nearestNeighborSearch(const QPoint point, const QRect searchRectangle, const std::function<bool(const QPoint)> &doesPointExist)
0637 {
0638     if (!searchRectangle.isValid()) {
0639         return std::nullopt;
0640     }
0641     // A valid QRect is non-empty, as described by QRect documentation…
0642 
0643     // Test for special case:
0644     // originalPixelPosition itself is within the image and non-transparent
0645     if (searchRectangle.contains(point)) {
0646         if (doesPointExist(point)) {
0647             return point;
0648         }
0649     }
0650 
0651     // We search the perimeter of a square that we keep moving out one pixel
0652     // at a time from the original point (“offset”).
0653 
0654     const auto hDistanceFromRect = distanceFromRange(searchRectangle.left(), //
0655                                                      point.x(),
0656                                                      searchRectangle.right());
0657     const auto vDistanceFromRect = distanceFromRange(searchRectangle.top(), //
0658                                                      point.y(),
0659                                                      searchRectangle.bottom());
0660     // As described at https://stackoverflow.com/a/307523:
0661     // An offset of “0” means that only the original point itself is searched
0662     // for. This is inefficient, because all eight search points will be
0663     // identical for an offset of “0”. And because we test yet for the
0664     // original point itself as a special case above, we can start here with
0665     // an offset ≥ 0.
0666     const auto initialOffset = qMax(1, //
0667                                     qMax(hDistanceFromRect, vDistanceFromRect));
0668     const auto hMaxDistance = qMax(qAbs(point.x() - searchRectangle.left()), //
0669                                    qAbs(point.x() - searchRectangle.right()));
0670     const auto vMaxDistance = qMax(qAbs(point.y() - searchRectangle.top()), //
0671                                    qAbs(point.y() - searchRectangle.bottom()));
0672     const auto maximumOffset = qMax(hMaxDistance, vMaxDistance);
0673     std::optional<QPoint> nearestPointTillNow;
0674     int nearestPointTillNowDistanceSquare = 0;
0675     qreal nearestPointTillNowDistance = 0.0;
0676     QPoint searchPoint;
0677     auto searchPointOffsets = [](int i, int j) -> QList<QPoint> {
0678         return QList<QPoint>({
0679             QPoint(i, j), // right
0680             QPoint(i, -j), // right
0681             QPoint(-i, j), // left
0682             QPoint(-i, -j), // left
0683             QPoint(j, i), // bottom
0684             QPoint(-j, i), // bottom
0685             QPoint(j, -i), // top
0686             QPoint(-j, -i) // top
0687         });
0688     };
0689     int i;
0690     int j;
0691     // As described at https://stackoverflow.com/a/307523:
0692     // The search starts at the four points that intersect the axes and moves
0693     // one pixel at a time towards the corners. (We have have 8 moving search
0694     // points). As soon as we locate an existing point, there is no need to
0695     // continue towards the corners, as the remaining points are all further
0696     // from the original point.
0697     for (i = initialOffset; //
0698          (i <= maximumOffset) && (!nearestPointTillNow.has_value()); //
0699          ++i //
0700     ) {
0701         for (j = 0; (j <= i) && (!nearestPointTillNow.has_value()); ++j) {
0702             const auto container = searchPointOffsets(i, j);
0703             for (const QPoint &temp : std::as_const(container)) {
0704                 // TODO A possible optimization might be to not always use all
0705                 // eight search points. Imagine you have an original point
0706                 // that is outside the image, at its left side. The search
0707                 // point on the left line of the search perimeter rectangle
0708                 // will always be out-of-boundary, so there is no need
0709                 // to calculate the search points, just to find out later
0710                 // that these points are outside the searchRectangle. But
0711                 // how could an elegant implementation look like?
0712                 searchPoint = point + temp;
0713                 if (searchRectangle.contains(searchPoint)) {
0714                     if (doesPointExist(searchPoint)) {
0715                         nearestPointTillNow = searchPoint;
0716                         nearestPointTillNowDistanceSquare = //
0717                             temp.x() * temp.x() + temp.y() * temp.y();
0718                         nearestPointTillNowDistance = qSqrt( //
0719                             nearestPointTillNowDistanceSquare);
0720                         break;
0721                     }
0722                 }
0723             }
0724         }
0725     }
0726 
0727     if (!nearestPointTillNow.has_value()) {
0728         // There is not one single pixel that is valid in the
0729         // whole searchRectangle.
0730         return nearestPointTillNow;
0731     }
0732 
0733     i += 1;
0734     // After the initial search for the nearest-neighbor-point, we must
0735     // continue to search the perimeter of wider squares until we reach an
0736     // offset of “nearestPointTillNowDistance”. However, the search points
0737     // no longer have to travel ("j") all the way to the corners: They can
0738     // stop when they reach a pixel that is farther away from the original
0739     // point than the current "nearest-neighbor-point" candidate."
0740     for (; i < nearestPointTillNowDistance; ++i) {
0741         qreal maximumJ = qSqrt(nearestPointTillNowDistanceSquare - i * i);
0742         for (j = 0; j < maximumJ; ++j) {
0743             const auto container = searchPointOffsets(i, j);
0744             for (const QPoint &temp : std::as_const(container)) {
0745                 searchPoint = point + temp;
0746                 if (searchRectangle.contains(searchPoint)) {
0747                     if (doesPointExist(searchPoint)) {
0748                         nearestPointTillNow = searchPoint;
0749                         nearestPointTillNowDistanceSquare = //
0750                             temp.x() * temp.x() + temp.y() * temp.y();
0751                         nearestPointTillNowDistance = qSqrt( //
0752                             nearestPointTillNowDistanceSquare);
0753                         maximumJ = qSqrt( //
0754                             nearestPointTillNowDistanceSquare - i * i);
0755                         break;
0756                     }
0757                 }
0758             }
0759         }
0760     }
0761 
0762     return nearestPointTillNow;
0763 }
0764 
0765 /** @brief Search the nearest in-gamut neighbor pixel.
0766  *
0767  * @param originalPixelPosition The pixel for which you search the nearest
0768  * neighbor, expressed in the coordinate system of the image. This pixel may
0769  * be inside or outside the image.
0770  * @returns The nearest non-transparent pixel of @ref m_chromaLightnessImage,
0771  *          if any. An empty value otherwise. If there are multiple
0772  *          non-transparent pixels at the same distance, it is
0773  *          indeterminate which one is returned. Note that the point itself
0774  *          is considered to be itself its nearest neighbor if it is within
0775  *          the image and non-transparent.
0776  *
0777  * @note This function waits until a full-quality @ref m_chromaLightnessImage
0778  * is available, which might take some time.
0779  *
0780  * @todo A possible optimization might be to search initially, after a new
0781  * image is available, entire columns, starting from the right, until we hit
0782  * the first column that has a non-transparent pixel. This information can be
0783  * used to reduce the search rectangle significantly. */
0784 std::optional<QPoint> ChromaLightnessDiagramPrivate::nearestInGamutPixelPosition(const QPoint originalPixelPosition)
0785 {
0786     m_chromaLightnessImage.refreshSync();
0787     const auto upToDateImage = m_chromaLightnessImage.getCache();
0788 
0789     auto isOpaqueFunction = [&upToDateImage](const QPoint point) -> bool {
0790         return (qAlpha(upToDateImage.pixel(point)) != 0);
0791     };
0792     return nearestNeighborSearch(originalPixelPosition, //
0793                                  QRect(QPoint(0, 0), upToDateImage.size()), //
0794                                  isOpaqueFunction);
0795 }
0796 
0797 /** @brief Find the nearest in-gamut pixel.
0798  *
0799  * The hue is assumed to be the current hue at @ref m_currentColor.
0800  * Chroma and lightness are sacrificed, but the hue is preserved. This function
0801  * works at the precision of the current @ref m_chromaLightnessImage.
0802  *
0803  * @param chroma Chroma of the original color.
0804  *
0805  * @param lightness Lightness of the original color.
0806  *
0807  * @note This function waits until a full-quality @ref m_chromaLightnessImage
0808  * is available, which might take some time.
0809  *
0810  * @returns The nearest in-gamut pixel with the same hue as the original
0811  * color. */
0812 PerceptualColor::LchDouble ChromaLightnessDiagramPrivate::nearestInGamutColorByAdjustingChromaLightness(const double chroma, const double lightness)
0813 {
0814     // Initialization
0815     LchDouble temp;
0816     temp.l = lightness;
0817     temp.c = chroma;
0818     temp.h = m_currentColor.h;
0819     if (temp.c < 0) {
0820         temp.c = 0;
0821     }
0822 
0823     // Return is we are within the gamut.
0824     // NOTE Calling isInGamut() is slower than simply testing for the pixel,
0825     // it is more exact.
0826     if (m_rgbColorSpace->isCielchD50InGamut(temp)) {
0827         return temp;
0828     }
0829 
0830     const auto imageHeight = calculateImageSizePhysical().height();
0831     QPoint myPixelPosition( //
0832         qRound(temp.c * (imageHeight - 1) / 100.0),
0833         qRound(imageHeight - 1 - temp.l * (imageHeight - 1) / 100.0));
0834 
0835     myPixelPosition = //
0836         nearestInGamutPixelPosition(myPixelPosition).value_or(QPoint(0, 0));
0837     LchDouble result = temp;
0838     result.c = myPixelPosition.x() * 100.0 / (imageHeight - 1);
0839     result.l = 100 - myPixelPosition.y() * 100.0 / (imageHeight - 1);
0840     return result;
0841 }
0842 
0843 } // namespace PerceptualColor