File indexing completed on 2024-10-13 04:16:21
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 "colorwheel.h" 0007 // Second, the private implementation. 0008 #include "colorwheel_p.h" // IWYU pragma: associated 0009 0010 #include "abstractdiagram.h" 0011 #include "cielchd50values.h" 0012 #include "colorwheelimage.h" 0013 #include "constpropagatingrawpointer.h" 0014 #include "constpropagatinguniquepointer.h" 0015 #include "helper.h" 0016 #include "helperconstants.h" 0017 #include "helpermath.h" 0018 #include "helperposixmath.h" 0019 #include "polarpointf.h" 0020 #include <qevent.h> 0021 #include <qimage.h> 0022 #include <qnamespace.h> 0023 #include <qpainter.h> 0024 #include <qpen.h> 0025 #include <qpoint.h> 0026 #include <qsharedpointer.h> 0027 #include <qwidget.h> 0028 0029 namespace PerceptualColor 0030 { 0031 /** @brief Constructor 0032 * 0033 * @param colorSpace The color space within which this widget should operate. 0034 * Can be created with @ref RgbColorSpaceFactory. 0035 * 0036 * @param parent The widget’s parent widget. This parameter will be passed 0037 * to the base class’s constructor. */ 0038 ColorWheel::ColorWheel(const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace, QWidget *parent) 0039 : AbstractDiagram(parent) 0040 , d_pointer(new ColorWheelPrivate(this, colorSpace)) 0041 { 0042 // Setup the color space must be the first thing to do because 0043 // other operations rely on a working color space. 0044 d_pointer->m_rgbColorSpace = colorSpace; 0045 0046 // Set focus policy 0047 // In Qt, usually focus (QWidget::hasFocus()) by mouse click is 0048 // either not accepted at all or accepted always for the hole rectangular 0049 // widget, depending on QWidget::focusPolicy(). This is not 0050 // convenient and intuitive for big, circular-shaped widgets like this one. 0051 // It would be nicer if the focus would only be accepted by mouse clicks 0052 // <em>within the circle itself</em>. Qt does not provide a build-in way to 0053 // do this. But a workaround to implement this behavior is possible: Set 0054 // QWidget::focusPolicy() to <em>not</em> accept focus by mouse 0055 // click. Then, reimplement mousePressEvent() and call 0056 // setFocus(Qt::MouseFocusReason) if the mouse click is within the 0057 // circle. Therefore, this class simply defaults to 0058 // Qt::FocusPolicy::TabFocus for QWidget::focusPolicy(). 0059 setFocusPolicy(Qt::FocusPolicy::TabFocus); 0060 } 0061 0062 /** @brief Default destructor */ 0063 ColorWheel::~ColorWheel() noexcept 0064 { 0065 } 0066 0067 /** @brief Constructor 0068 * 0069 * @param backLink Pointer to the object from which <em>this</em> object 0070 * is the private implementation. 0071 * 0072 * @param colorSpace The color space within which this widget should operate. */ 0073 ColorWheelPrivate::ColorWheelPrivate(ColorWheel *backLink, const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace) 0074 : m_wheelImage(colorSpace) 0075 , q_pointer(backLink) 0076 { 0077 // Initialization 0078 m_hue = CielchD50Values::neutralHue; 0079 } 0080 0081 /** @brief Convert widget pixel positions to wheel coordinate points. 0082 * 0083 * @param position The position of a pixel of the widget coordinate 0084 * system. The given value does not necessarily need to be within the 0085 * actual displayed diagram or even the gamut itself. It might even be 0086 * negative. 0087 * 0088 * @returns A coordinate point relative to a polar coordinate system 0089 * who’s center is exactly in the middle of the displayed wheel. Measured 0090 * in <em>device-independent pixels</em>. 0091 * 0092 * @sa @ref fromWheelToWidgetCoordinates */ 0093 PolarPointF ColorWheelPrivate::fromWidgetPixelPositionToWheelCoordinates(const QPoint position) const 0094 { 0095 const qreal radius = q_pointer->maximumWidgetSquareSize() / 2.0; 0096 const QPointF temp{position.x() - radius + 0.5, radius - position.y() + 0.5}; 0097 return PolarPointF(temp); 0098 } 0099 0100 /** @brief Convert wheel coordinate points to widget coordinate points. 0101 * 0102 * @param wheelCoordinates A coordinate point relative to a polar coordinate 0103 * system who’s center is exactly in the middle of the displayed wheel. 0104 * Measured in <em>device-independent pixels</em>. 0105 * 0106 * @returns The same coordinate point relative to the coordinate system of 0107 * this widget. Measured in <em>device-independent pixels</em>. 0108 * 0109 * @sa @ref fromWidgetPixelPositionToWheelCoordinates */ 0110 QPointF ColorWheelPrivate::fromWheelToWidgetCoordinates(const PolarPointF wheelCoordinates) const 0111 { 0112 const qreal radius = q_pointer->maximumWidgetSquareSize() / 2.0; 0113 QPointF result = wheelCoordinates.toCartesian(); 0114 result.setX(result.x() + radius); 0115 result.setY(radius - result.y()); 0116 return result; 0117 } 0118 0119 /** @brief React on a mouse press event. 0120 * 0121 * Reimplemented from base class. 0122 * 0123 * Does not differentiate between left, middle and right mouse click. 0124 * 0125 * If the mouse is clicked within the wheel ribbon, than the handle is placed 0126 * here and further mouse movements are tracked. 0127 * 0128 * @param event The corresponding mouse event 0129 * 0130 * @internal 0131 * 0132 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */ 0133 void ColorWheel::mousePressEvent(QMouseEvent *event) 0134 { 0135 const qreal radius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator(); 0136 PolarPointF myPolarPoint = d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos()); 0137 0138 // Ignore clicks outside the wheel 0139 if (myPolarPoint.radius() > radius) { 0140 // Make sure default coordinates like drag-window 0141 // in KDE’s Breeze widget style works: 0142 event->ignore(); 0143 return; 0144 } 0145 0146 // If inside the wheel (either in the wheel ribbon itself or in the hole 0147 // in the middle), take focus: 0148 setFocus(Qt::MouseFocusReason); 0149 0150 if (myPolarPoint.radius() > radius - gradientThickness()) { 0151 d_pointer->m_isMouseEventActive = true; 0152 setHue(myPolarPoint.angleDegree()); 0153 } else { 0154 // Make sure default coordinates like drag-window 0155 // in KDE’s Breeze widget style works: 0156 event->ignore(); 0157 } 0158 0159 return; 0160 } 0161 0162 /** @brief React on a mouse move event. 0163 * 0164 * Reimplemented from base class. 0165 * 0166 * Reacts only on mouse move events if previously there had been a mouse press 0167 * event that had been accepted. If previously there had not been a mouse 0168 * press event, the mouse move event is ignored. 0169 * 0170 * @param event The corresponding mouse event 0171 * 0172 * @internal 0173 * 0174 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */ 0175 void ColorWheel::mouseMoveEvent(QMouseEvent *event) 0176 { 0177 if (d_pointer->m_isMouseEventActive) { 0178 setHue(d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos()).angleDegree()); 0179 } else { 0180 // Make sure default coordinates like drag-window in KDE’s Breeze 0181 // widget style works 0182 event->ignore(); 0183 } 0184 } 0185 0186 /** @brief React on a mouse release event. 0187 * 0188 * Reimplemented from base class. Does not differentiate between left, 0189 * middle and right mouse click. 0190 * 0191 * @param event The corresponding mouse event 0192 * 0193 * @internal 0194 * 0195 * @sa @ref ColorWheelPrivate::m_isMouseEventActive 0196 * 0197 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */ 0198 void ColorWheel::mouseReleaseEvent(QMouseEvent *event) 0199 { 0200 if (d_pointer->m_isMouseEventActive) { 0201 d_pointer->m_isMouseEventActive = false; 0202 setHue(d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos()).angleDegree()); 0203 } else { 0204 // Make sure default coordinates like drag-window in KDE’s Breeze 0205 // widget style works 0206 event->ignore(); 0207 } 0208 } 0209 0210 /** @brief React on a mouse wheel event. 0211 * 0212 * Reimplemented from base class. 0213 * 0214 * Scrolling up raises the hue value, scrolling down lowers the hue value. 0215 * Of course, the point at 0°/360° it not blocking. 0216 * 0217 * @param event The corresponding mouse event */ 0218 void ColorWheel::wheelEvent(QWheelEvent *event) 0219 { 0220 const qreal radius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator(); 0221 // Though QWheelEvent::position() returns a floating point 0222 // value, this value seems to corresponds to a pixel position 0223 // and not a coordinate point. Therefore, we convert to QPoint. 0224 const PolarPointF myPolarPoint = // 0225 d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->position().toPoint()); 0226 if ( 0227 // Do nothing while mouse movement is tracked anyway. This would 0228 // be confusing: 0229 (!d_pointer->m_isMouseEventActive) 0230 // Only react on wheel events when its in the wheel ribbon or in 0231 // the inner hole: 0232 && (myPolarPoint.radius() <= radius) 0233 // Only react on good old vertical wheels, and not on horizontal wheels: 0234 && (event->angleDelta().y() != 0) 0235 // then: 0236 ) { 0237 d_pointer->setHueNormalized(d_pointer->m_hue + standardWheelStepCount(event) * singleStepHue); 0238 } else { 0239 event->ignore(); 0240 } 0241 } 0242 0243 /** @brief React on key press events. 0244 * 0245 * Reimplemented from base class. 0246 * 0247 * Reacts on key press events. When the <em>plus</em> key or the <em>minus</em> 0248 * key are pressed, it raises or lowers the hue. When <tt>Qt::Key_Insert</tt> 0249 * or <tt>Qt::Key_Delete</tt> are pressed, it raises or lowers the hue faster. 0250 * 0251 * @param event the corresponding event 0252 * 0253 * @internal 0254 * 0255 * @todo The keys are chosen to not conflict with @ref ChromaHueDiagram. But: 0256 * They are a little strange. Does this really make sense? */ 0257 void ColorWheel::keyPressEvent(QKeyEvent *event) 0258 { 0259 switch (event->key()) { 0260 case Qt::Key_Plus: 0261 d_pointer->setHueNormalized(d_pointer->m_hue + singleStepHue); 0262 break; 0263 case Qt::Key_Minus: 0264 d_pointer->setHueNormalized(d_pointer->m_hue - singleStepHue); 0265 break; 0266 case Qt::Key_Insert: 0267 d_pointer->setHueNormalized(d_pointer->m_hue + pageStepHue); 0268 break; 0269 case Qt::Key_Delete: 0270 d_pointer->setHueNormalized(d_pointer->m_hue - pageStepHue); 0271 break; 0272 default: 0273 /* Quote from Qt documentation: 0274 * 0275 * If you reimplement this handler, it is very important 0276 * that you call the base class implementation if you do not 0277 * act upon the key. 0278 * 0279 * The default implementation closes popup widgets if the user 0280 * presses the key sequence for QKeySequence::Cancel (typically 0281 * the Escape key). Otherwise the event is ignored, so that the 0282 * widget’s parent can interpret it. */ 0283 QWidget::keyPressEvent(event); 0284 break; 0285 } 0286 } 0287 0288 /** @brief Paint the widget. 0289 * 0290 * Reimplemented from base class. 0291 * 0292 * @param event the paint event 0293 * 0294 * @internal 0295 * 0296 * The wheel is painted using @ref ColorWheelPrivate::m_wheelImage. 0297 * The focus indicator (if any) and the handle are painted on-the-fly. 0298 * 0299 * @todo Make the wheel to be drawn horizontally and vertically aligned?? Or 0300 * better top-left aligned for LTR layouts and top-right aligned for RTL 0301 * layouts? 0302 * 0303 * @todo Better design (smaller wheel ribbon?) for small widget sizes */ 0304 void ColorWheel::paintEvent(QPaintEvent *event) 0305 { 0306 Q_UNUSED(event) 0307 // We do not paint directly on the widget, but on a QImage buffer first: 0308 // Render anti-aliased looks better. But as Qt documentation says: 0309 // 0310 // “Renderhints are used to specify flags to QPainter that may or 0311 // may not be respected by any given engine.” 0312 // 0313 // Painting here directly on the widget might lead to different 0314 // anti-aliasing results depending on the underlying window system. This 0315 // is especially problematic as anti-aliasing might shift or not a pixel 0316 // to the left or to the right. So we paint on a QImage first. As QImage 0317 // (at difference to QPixmap and a QWidget) is independent of native 0318 // platform rendering, it guarantees identical anti-aliasing results on 0319 // all platforms. Here the quote from QPainter class documentation: 0320 // 0321 // “To get the optimal rendering result using QPainter, you should 0322 // use the platform independent QImage as paint device; i.e. using 0323 // QImage will ensure that the result has an identical pixel 0324 // representation on any platform.” 0325 QImage paintBuffer(maximumPhysicalSquareSize(), // width 0326 maximumPhysicalSquareSize(), // height 0327 QImage::Format_ARGB32_Premultiplied // format 0328 ); 0329 paintBuffer.fill(Qt::transparent); 0330 paintBuffer.setDevicePixelRatio(devicePixelRatioF()); 0331 QPainter bufferPainter(&paintBuffer); 0332 0333 // Paint the color wheel 0334 bufferPainter.setRenderHint(QPainter::Antialiasing, false); 0335 // As devicePixelRatioF() might have changed, we make sure everything 0336 // that might depend on devicePixelRatioF() is updated before painting. 0337 d_pointer->m_wheelImage.setBorder(spaceForFocusIndicator() * devicePixelRatioF()); 0338 d_pointer->m_wheelImage.setDevicePixelRatioF(devicePixelRatioF()); 0339 d_pointer->m_wheelImage.setImageSize(maximumPhysicalSquareSize()); 0340 d_pointer->m_wheelImage.setWheelThickness(gradientThickness() * devicePixelRatioF()); 0341 bufferPainter.drawImage(QPoint(0, 0), // image position (top-left) 0342 d_pointer->m_wheelImage.getImage() // the image itself 0343 ); 0344 0345 // Paint the handle 0346 const qreal wheelOuterRadius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator(); 0347 // Get widget coordinates for the handle 0348 QPointF myHandleInner = d_pointer->fromWheelToWidgetCoordinates( 0349 // Inner point at the wheel: 0350 PolarPointF(wheelOuterRadius - gradientThickness(), // x 0351 d_pointer->m_hue // y 0352 )); 0353 QPointF myHandleOuter = d_pointer->fromWheelToWidgetCoordinates( 0354 // Outer point at the wheel: 0355 PolarPointF(wheelOuterRadius, d_pointer->m_hue)); 0356 // Draw the line 0357 QPen pen; 0358 pen.setWidth(handleOutlineThickness()); 0359 pen.setCapStyle(Qt::FlatCap); 0360 pen.setColor(Qt::black); 0361 bufferPainter.setPen(pen); 0362 bufferPainter.setRenderHint(QPainter::Antialiasing, true); 0363 bufferPainter.drawLine(myHandleInner, myHandleOuter); 0364 0365 // Paint a focus indicator if the widget has the focus 0366 if (hasFocus()) { 0367 bufferPainter.setRenderHint(QPainter::Antialiasing, true); 0368 pen = QPen(); 0369 pen.setWidth(handleOutlineThickness()); 0370 pen.setColor(focusIndicatorColor()); 0371 bufferPainter.setPen(pen); 0372 const qreal center = maximumWidgetSquareSize() / 2.0; 0373 bufferPainter.drawEllipse( 0374 // center: 0375 QPointF(center, center), 0376 // x radius: 0377 center - handleOutlineThickness() / 2.0, 0378 // y radius: 0379 center - handleOutlineThickness() / 2.0); 0380 } 0381 0382 // Paint the buffer to the actual widget 0383 QPainter widgetPainter(this); 0384 widgetPainter.setRenderHint(QPainter::Antialiasing, false); 0385 widgetPainter.drawImage(QPoint(0, 0), paintBuffer); 0386 } 0387 0388 /** @brief React on a resize event. 0389 * 0390 * Reimplemented from base class. 0391 * 0392 * @param event The corresponding resize event */ 0393 void ColorWheel::resizeEvent(QResizeEvent *event) 0394 { 0395 Q_UNUSED(event) 0396 0397 // Update the widget content 0398 d_pointer->m_wheelImage.setImageSize(maximumPhysicalSquareSize()); 0399 /* As by Qt documentation: 0400 * “The widget will be erased and receive a paint event immediately 0401 * after processing the resize event. No drawing need be (or should 0402 * be) done inside this handler.” */ 0403 } 0404 0405 // No documentation here (documentation of properties 0406 // and its getters are in the header) 0407 qreal ColorWheel::hue() const 0408 { 0409 return d_pointer->m_hue; 0410 } 0411 0412 /** @brief Setter for the @ref hue property. 0413 * @param newHue the new hue */ 0414 void ColorWheel::setHue(const qreal newHue) 0415 { 0416 if (d_pointer->m_hue != newHue) { 0417 d_pointer->m_hue = newHue; 0418 Q_EMIT hueChanged(d_pointer->m_hue); 0419 update(); 0420 } 0421 } 0422 0423 /** @brief Setter for the @ref ColorWheel::hue property. 0424 * @param newHue the new hue 0425 * @post Normalizes newHue, and than sets @ref ColorWheel::hue to the 0426 * normalized value. */ 0427 void ColorWheelPrivate::setHueNormalized(const qreal newHue) 0428 { 0429 const qreal temp = normalizedAngle360(newHue); 0430 q_pointer->setHue(temp); 0431 } 0432 0433 /** @brief Recommended size for the widget. 0434 * 0435 * Reimplemented from base class. 0436 * 0437 * @returns Recommended size for the widget. 0438 * 0439 * @sa @ref minimumSizeHint() */ 0440 QSize ColorWheel::sizeHint() const 0441 { 0442 return minimumSizeHint() * scaleFromMinumumSizeHintToSizeHint; 0443 } 0444 0445 /** @brief Recommended minimum size for the widget. 0446 * 0447 * Reimplemented from base class. 0448 * 0449 * @returns Recommended minimum size for the widget. 0450 * 0451 * @sa @ref sizeHint() */ 0452 QSize ColorWheel::minimumSizeHint() const 0453 { 0454 // We interpret the gradientMinimumLength() as the length between two 0455 // poles of human perception. Around the wheel, there are four of them 0456 // (0° red, 90° yellow, 180° green, 270° blue). So the circumference of 0457 // the inner circle of the wheel is 4 × gradientMinimumLength(). By 0458 // dividing it by π, we get the required inner diameter: 0459 const qreal innerDiameter = 4 * gradientMinimumLength() / pi; 0460 const int size = qRound(innerDiameter + 2 * gradientThickness() + 2 * spaceForFocusIndicator()); 0461 // Expand to the global minimum size for GUI elements 0462 return QSize(size, size); 0463 } 0464 0465 /** @brief The empty space around the diagrams reserved for the focus 0466 * indicator. 0467 * 0468 * This is a simple redirect to @ref AbstractDiagram::spaceForFocusIndicator(). 0469 * It is meant to allow access from friend classes of @ref ColorWheel. 0470 * 0471 * Measured in <em>device-independent pixels</em>. 0472 * 0473 * @returns The empty space around diagrams (distance between widget outline 0474 * and color wheel outline) reserved for the focus indicator. */ 0475 int ColorWheelPrivate::border() const 0476 { 0477 return q_pointer->spaceForFocusIndicator(); 0478 } 0479 0480 /** @brief The inner diameter of the color wheel. 0481 * 0482 * It is meant to allow access from friend classes of @ref ColorWheel. 0483 * 0484 * @returns The inner diameter of the color wheel, measured in 0485 * <em>device-independent pixels</em>. This is the diameter of the empty 0486 * circle within the color wheel. */ 0487 qreal ColorWheelPrivate::innerDiameter() const 0488 { 0489 return 0490 // Size for the widget: 0491 q_pointer->maximumWidgetSquareSize() 0492 // Reduce space for the wheel ribbon: 0493 - 2 * q_pointer->gradientThickness() 0494 // Reduce space for the focus indicator (border around wheel ribbon): 0495 - 2 * q_pointer->spaceForFocusIndicator(); 0496 } 0497 0498 } // namespace PerceptualColor