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