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