Perceptual Color

colorwheel.cpp
1// SPDX-FileCopyrightText: Lukas Sommer <sommerluk@gmail.com>
2// SPDX-License-Identifier: BSD-2-Clause OR MIT
3
4// Own headers
5// First the interface, which forces the header to be self-contained.
6#include "colorwheel.h"
7// Second, the private implementation.
8#include "colorwheel_p.h" // IWYU pragma: associated
9
10#include "abstractdiagram.h"
11#include "cielchd50values.h"
12#include "colorwheelimage.h"
13#include "constpropagatingrawpointer.h"
14#include "constpropagatinguniquepointer.h"
15#include "helper.h"
16#include "helperconstants.h"
17#include "helpermath.h"
18#include "helperposixmath.h"
19#include "polarpointf.h"
20#include <qevent.h>
21#include <qimage.h>
22#include <qnamespace.h>
23#include <qpainter.h>
24#include <qpen.h>
25#include <qpoint.h>
26#include <qsharedpointer.h>
27#include <qwidget.h>
28
29namespace PerceptualColor
30{
31/** @brief Constructor
32 *
33 * @param colorSpace The color space within which this widget should operate.
34 * Can be created with @ref RgbColorSpaceFactory.
35 *
36 * @param parent The widget’s parent widget. This parameter will be passed
37 * to the base class’s constructor. */
39 : AbstractDiagram(parent)
40 , d_pointer(new ColorWheelPrivate(this, colorSpace))
41{
42 // Setup the color space must be the first thing to do because
43 // other operations rely on a working color space.
44 d_pointer->m_rgbColorSpace = colorSpace;
45
46 // Set focus policy
47 // In Qt, usually focus (QWidget::hasFocus()) by mouse click is
48 // either not accepted at all or accepted always for the hole rectangular
49 // widget, depending on QWidget::focusPolicy(). This is not
50 // convenient and intuitive for big, circular-shaped widgets like this one.
51 // It would be nicer if the focus would only be accepted by mouse clicks
52 // <em>within the circle itself</em>. Qt does not provide a build-in way to
53 // do this. But a workaround to implement this behavior is possible: Set
54 // QWidget::focusPolicy() to <em>not</em> accept focus by mouse
55 // click. Then, reimplement mousePressEvent() and call
56 // setFocus(Qt::MouseFocusReason) if the mouse click is within the
57 // circle. Therefore, this class simply defaults to
58 // Qt::FocusPolicy::TabFocus for QWidget::focusPolicy().
59 setFocusPolicy(Qt::FocusPolicy::TabFocus);
60}
61
62/** @brief Default destructor */
64{
65}
66
67/** @brief Constructor
68 *
69 * @param backLink Pointer to the object from which <em>this</em> object
70 * is the private implementation.
71 *
72 * @param colorSpace The color space within which this widget should operate. */
73ColorWheelPrivate::ColorWheelPrivate(ColorWheel *backLink, const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace)
74 : m_wheelImage(colorSpace)
75 , q_pointer(backLink)
76{
77 // Initialization
78 m_hue = CielchD50Values::neutralHue;
79}
80
81/** @brief Convert widget pixel positions to wheel coordinate points.
82 *
83 * @param position The position of a pixel of the widget coordinate
84 * system. The given value does not necessarily need to be within the
85 * actual displayed diagram or even the gamut itself. It might even be
86 * negative.
87 *
88 * @returns A coordinate point relative to a polar coordinate system
89 * who’s center is exactly in the middle of the displayed wheel. Measured
90 * in <em>device-independent pixels</em>.
91 *
92 * @sa @ref fromWheelToWidgetCoordinates */
93PolarPointF ColorWheelPrivate::fromWidgetPixelPositionToWheelCoordinates(const QPoint position) const
94{
95 const qreal radius = q_pointer->maximumWidgetSquareSize() / 2.0;
96 const QPointF temp{position.x() - radius + 0.5, radius - position.y() + 0.5};
97 return PolarPointF(temp);
98}
99
100/** @brief Convert wheel coordinate points to widget coordinate points.
101 *
102 * @param wheelCoordinates A coordinate point relative to a polar coordinate
103 * system who’s center is exactly in the middle of the displayed wheel.
104 * Measured in <em>device-independent pixels</em>.
105 *
106 * @returns The same coordinate point relative to the coordinate system of
107 * this widget. Measured in <em>device-independent pixels</em>.
108 *
109 * @sa @ref fromWidgetPixelPositionToWheelCoordinates */
110QPointF ColorWheelPrivate::fromWheelToWidgetCoordinates(const PolarPointF wheelCoordinates) const
111{
112 const qreal radius = q_pointer->maximumWidgetSquareSize() / 2.0;
113 QPointF result = wheelCoordinates.toCartesian();
114 result.setX(result.x() + radius);
115 result.setY(radius - result.y());
116 return result;
117}
118
119/** @brief React on a mouse press event.
120 *
121 * Reimplemented from base class.
122 *
123 * Does not differentiate between left, middle and right mouse click.
124 *
125 * If the mouse is clicked within the wheel ribbon, than the handle is placed
126 * here and further mouse movements are tracked.
127 *
128 * @param event The corresponding mouse event
129 *
130 * @internal
131 *
132 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */
134{
135 const qreal radius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator();
136 PolarPointF myPolarPoint = d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos());
137
138 // Ignore clicks outside the wheel
139 if (myPolarPoint.radius() > radius) {
140 // Make sure default coordinates like drag-window
141 // in KDE’s Breeze widget style works:
142 event->ignore();
143 return;
144 }
145
146 // If inside the wheel (either in the wheel ribbon itself or in the hole
147 // in the middle), take focus:
149
150 if (myPolarPoint.radius() > radius - gradientThickness()) {
151 d_pointer->m_isMouseEventActive = true;
152 setHue(myPolarPoint.angleDegree());
153 } else {
154 // Make sure default coordinates like drag-window
155 // in KDE’s Breeze widget style works:
156 event->ignore();
157 }
158
159 return;
160}
161
162/** @brief React on a mouse move event.
163 *
164 * Reimplemented from base class.
165 *
166 * Reacts only on mouse move events if previously there had been a mouse press
167 * event that had been accepted. If previously there had not been a mouse
168 * press event, the mouse move event is ignored.
169 *
170 * @param event The corresponding mouse event
171 *
172 * @internal
173 *
174 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */
176{
177 if (d_pointer->m_isMouseEventActive) {
178 setHue(d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos()).angleDegree());
179 } else {
180 // Make sure default coordinates like drag-window in KDE’s Breeze
181 // widget style works
182 event->ignore();
183 }
184}
185
186/** @brief React on a mouse release event.
187 *
188 * Reimplemented from base class. Does not differentiate between left,
189 * middle and right mouse click.
190 *
191 * @param event The corresponding mouse event
192 *
193 * @internal
194 *
195 * @sa @ref ColorWheelPrivate::m_isMouseEventActive
196 *
197 * @sa @ref ColorWheelPrivate::m_isMouseEventActive */
199{
200 if (d_pointer->m_isMouseEventActive) {
201 d_pointer->m_isMouseEventActive = false;
202 setHue(d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->pos()).angleDegree());
203 } else {
204 // Make sure default coordinates like drag-window in KDE’s Breeze
205 // widget style works
206 event->ignore();
207 }
208}
209
210/** @brief React on a mouse wheel event.
211 *
212 * Reimplemented from base class.
213 *
214 * Scrolling up raises the hue value, scrolling down lowers the hue value.
215 * Of course, the point at 0°/360° it not blocking.
216 *
217 * @param event The corresponding mouse event */
219{
220 const qreal radius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator();
221 // Though QWheelEvent::position() returns a floating point
222 // value, this value seems to corresponds to a pixel position
223 // and not a coordinate point. Therefore, we convert to QPoint.
224 const PolarPointF myPolarPoint = //
225 d_pointer->fromWidgetPixelPositionToWheelCoordinates(event->position().toPoint());
226 if (
227 // Do nothing while mouse movement is tracked anyway. This would
228 // be confusing:
229 (!d_pointer->m_isMouseEventActive)
230 // Only react on wheel events when its in the wheel ribbon or in
231 // the inner hole:
232 && (myPolarPoint.radius() <= radius)
233 // Only react on good old vertical wheels, and not on horizontal wheels:
234 && (event->angleDelta().y() != 0)
235 // then:
236 ) {
237 d_pointer->setHueNormalized(d_pointer->m_hue + standardWheelStepCount(event) * singleStepHue);
238 } else {
239 event->ignore();
240 }
241}
242
243/** @brief React on key press events.
244 *
245 * Reimplemented from base class.
246 *
247 * Reacts on key press events. When the <em>plus</em> key or the <em>minus</em>
248 * key are pressed, it raises or lowers the hue. When <tt>Qt::Key_Insert</tt>
249 * or <tt>Qt::Key_Delete</tt> are pressed, it raises or lowers the hue faster.
250 *
251 * @param event the corresponding event
252 *
253 * @internal
254 *
255 * @todo The keys are chosen to not conflict with @ref ChromaHueDiagram. But:
256 * They are a little strange. Does this really make sense? */
258{
259 switch (event->key()) {
260 case Qt::Key_Plus:
261 d_pointer->setHueNormalized(d_pointer->m_hue + singleStepHue);
262 break;
263 case Qt::Key_Minus:
264 d_pointer->setHueNormalized(d_pointer->m_hue - singleStepHue);
265 break;
266 case Qt::Key_Insert:
267 d_pointer->setHueNormalized(d_pointer->m_hue + pageStepHue);
268 break;
269 case Qt::Key_Delete:
270 d_pointer->setHueNormalized(d_pointer->m_hue - pageStepHue);
271 break;
272 default:
273 /* Quote from Qt documentation:
274 *
275 * If you reimplement this handler, it is very important
276 * that you call the base class implementation if you do not
277 * act upon the key.
278 *
279 * The default implementation closes popup widgets if the user
280 * presses the key sequence for QKeySequence::Cancel (typically
281 * the Escape key). Otherwise the event is ignored, so that the
282 * widget’s parent can interpret it. */
284 break;
285 }
286}
287
288/** @brief Paint the widget.
289 *
290 * Reimplemented from base class.
291 *
292 * @param event the paint event
293 *
294 * @internal
295 *
296 * The wheel is painted using @ref ColorWheelPrivate::m_wheelImage.
297 * The focus indicator (if any) and the handle are painted on-the-fly.
298 *
299 * @todo Make the wheel to be drawn horizontally and vertically aligned?? Or
300 * better top-left aligned for LTR layouts and top-right aligned for RTL
301 * layouts?
302 *
303 * @todo Better design (smaller wheel ribbon?) for small widget sizes */
305{
306 Q_UNUSED(event)
307 // We do not paint directly on the widget, but on a QImage buffer first:
308 // Render anti-aliased looks better. But as Qt documentation says:
309 //
310 // “Renderhints are used to specify flags to QPainter that may or
311 // may not be respected by any given engine.”
312 //
313 // Painting here directly on the widget might lead to different
314 // anti-aliasing results depending on the underlying window system. This
315 // is especially problematic as anti-aliasing might shift or not a pixel
316 // to the left or to the right. So we paint on a QImage first. As QImage
317 // (at difference to QPixmap and a QWidget) is independent of native
318 // platform rendering, it guarantees identical anti-aliasing results on
319 // all platforms. Here the quote from QPainter class documentation:
320 //
321 // “To get the optimal rendering result using QPainter, you should
322 // use the platform independent QImage as paint device; i.e. using
323 // QImage will ensure that the result has an identical pixel
324 // representation on any platform.”
325 QImage paintBuffer(maximumPhysicalSquareSize(), // width
326 maximumPhysicalSquareSize(), // height
328 );
329 paintBuffer.fill(Qt::transparent);
331 QPainter bufferPainter(&paintBuffer);
332
333 // Paint the color wheel
334 bufferPainter.setRenderHint(QPainter::Antialiasing, false);
335 // As devicePixelRatioF() might have changed, we make sure everything
336 // that might depend on devicePixelRatioF() is updated before painting.
337 d_pointer->m_wheelImage.setBorder(spaceForFocusIndicator() * devicePixelRatioF());
338 d_pointer->m_wheelImage.setDevicePixelRatioF(devicePixelRatioF());
339 d_pointer->m_wheelImage.setImageSize(maximumPhysicalSquareSize());
340 d_pointer->m_wheelImage.setWheelThickness(gradientThickness() * devicePixelRatioF());
341 bufferPainter.drawImage(QPoint(0, 0), // image position (top-left)
342 d_pointer->m_wheelImage.getImage() // the image itself
343 );
344
345 // Paint the handle
346 const qreal wheelOuterRadius = maximumWidgetSquareSize() / 2.0 - spaceForFocusIndicator();
347 // Get widget coordinates for the handle
348 QPointF myHandleInner = d_pointer->fromWheelToWidgetCoordinates(
349 // Inner point at the wheel:
350 PolarPointF(wheelOuterRadius - gradientThickness(), // x
351 d_pointer->m_hue // y
352 ));
353 QPointF myHandleOuter = d_pointer->fromWheelToWidgetCoordinates(
354 // Outer point at the wheel:
355 PolarPointF(wheelOuterRadius, d_pointer->m_hue));
356 // Draw the line
357 QPen pen;
360 pen.setColor(Qt::black);
361 bufferPainter.setPen(pen);
362 bufferPainter.setRenderHint(QPainter::Antialiasing, true);
363 bufferPainter.drawLine(myHandleInner, myHandleOuter);
364
365 // Paint a focus indicator if the widget has the focus
366 if (hasFocus()) {
367 bufferPainter.setRenderHint(QPainter::Antialiasing, true);
368 pen = QPen();
371 bufferPainter.setPen(pen);
372 const qreal center = maximumWidgetSquareSize() / 2.0;
373 bufferPainter.drawEllipse(
374 // center:
375 QPointF(center, center),
376 // x radius:
377 center - handleOutlineThickness() / 2.0,
378 // y radius:
379 center - handleOutlineThickness() / 2.0);
380 }
381
382 // Paint the buffer to the actual widget
383 QPainter widgetPainter(this);
384 widgetPainter.setRenderHint(QPainter::Antialiasing, false);
385 widgetPainter.drawImage(QPoint(0, 0), paintBuffer);
386}
387
388/** @brief React on a resize event.
389 *
390 * Reimplemented from base class.
391 *
392 * @param event The corresponding resize event */
394{
395 Q_UNUSED(event)
396
397 // Update the widget content
398 d_pointer->m_wheelImage.setImageSize(maximumPhysicalSquareSize());
399 /* As by Qt documentation:
400 * “The widget will be erased and receive a paint event immediately
401 * after processing the resize event. No drawing need be (or should
402 * be) done inside this handler.” */
403}
404
405// No documentation here (documentation of properties
406// and its getters are in the header)
407qreal ColorWheel::hue() const
408{
409 return d_pointer->m_hue;
410}
411
412/** @brief Setter for the @ref hue property.
413 * @param newHue the new hue */
414void ColorWheel::setHue(const qreal newHue)
415{
416 if (d_pointer->m_hue != newHue) {
417 d_pointer->m_hue = newHue;
418 Q_EMIT hueChanged(d_pointer->m_hue);
419 update();
420 }
421}
422
423/** @brief Setter for the @ref ColorWheel::hue property.
424 * @param newHue the new hue
425 * @post Normalizes newHue, and than sets @ref ColorWheel::hue to the
426 * normalized value. */
427void ColorWheelPrivate::setHueNormalized(const qreal newHue)
428{
429 const qreal temp = normalizedAngle360(newHue);
430 q_pointer->setHue(temp);
431}
432
433/** @brief Recommended size for the widget.
434 *
435 * Reimplemented from base class.
436 *
437 * @returns Recommended size for the widget.
438 *
439 * @sa @ref minimumSizeHint() */
441{
442 return minimumSizeHint() * scaleFromMinumumSizeHintToSizeHint;
443}
444
445/** @brief Recommended minimum size for the widget.
446 *
447 * Reimplemented from base class.
448 *
449 * @returns Recommended minimum size for the widget.
450 *
451 * @sa @ref sizeHint() */
453{
454 // We interpret the gradientMinimumLength() as the length between two
455 // poles of human perception. Around the wheel, there are four of them
456 // (0° red, 90° yellow, 180° green, 270° blue). So the circumference of
457 // the inner circle of the wheel is 4 × gradientMinimumLength(). By
458 // dividing it by π, we get the required inner diameter:
459 const qreal innerDiameter = 4 * gradientMinimumLength() / pi;
460 const int size = qRound(innerDiameter + 2 * gradientThickness() + 2 * spaceForFocusIndicator());
461 // Expand to the global minimum size for GUI elements
462 return QSize(size, size);
463}
464
465/** @brief The empty space around the diagrams reserved for the focus
466 * indicator.
467 *
468 * This is a simple redirect to @ref AbstractDiagram::spaceForFocusIndicator().
469 * It is meant to allow access from friend classes of @ref ColorWheel.
470 *
471 * Measured in <em>device-independent pixels</em>.
472 *
473 * @returns The empty space around diagrams (distance between widget outline
474 * and color wheel outline) reserved for the focus indicator. */
475int ColorWheelPrivate::border() const
476{
477 return q_pointer->spaceForFocusIndicator();
478}
479
480/** @brief The inner diameter of the color wheel.
481 *
482 * It is meant to allow access from friend classes of @ref ColorWheel.
483 *
484 * @returns The inner diameter of the color wheel, measured in
485 * <em>device-independent pixels</em>. This is the diameter of the empty
486 * circle within the color wheel. */
487qreal ColorWheelPrivate::innerDiameter() const
488{
489 return
490 // Size for the widget:
491 q_pointer->maximumWidgetSquareSize()
492 // Reduce space for the wheel ribbon:
493 - 2 * q_pointer->gradientThickness()
494 // Reduce space for the focus indicator (border around wheel ribbon):
495 - 2 * q_pointer->spaceForFocusIndicator();
496}
497
498} // namespace PerceptualColor
Base class for LCH diagrams.
int gradientMinimumLength() const
The minimum length of a color gradient.
int spaceForFocusIndicator() const
The empty space around diagrams reserved for the focus indicator.
int handleOutlineThickness() const
The outline thickness of a handle.
int gradientThickness() const
The thickness of a color gradient.
QColor focusIndicatorColor() const
The color for painting focus indicators.
int maximumPhysicalSquareSize() const
The maximum possible size of a square within the widget, measured in physical pixels.
qreal maximumWidgetSquareSize() const
The maximum possible size of a square within the widget, measured in device-independent pixels.
A color wheel widget.
Definition colorwheel.h:65
virtual void mousePressEvent(QMouseEvent *event) override
React on a mouse press event.
virtual void paintEvent(QPaintEvent *event) override
Paint the widget.
Q_INVOKABLE ColorWheel(const QSharedPointer< PerceptualColor::RgbColorSpace > &colorSpace, QWidget *parent=nullptr)
Constructor.
virtual void wheelEvent(QWheelEvent *event) override
React on a mouse wheel event.
virtual QSize minimumSizeHint() const override
Recommended minimum size for the widget.
qreal hue
The currently selected hue.
Definition colorwheel.h:94
virtual void mouseReleaseEvent(QMouseEvent *event) override
React on a mouse release event.
virtual void mouseMoveEvent(QMouseEvent *event) override
React on a mouse move event.
void setHue(const qreal newHue)
Setter for the hue property.
virtual void resizeEvent(QResizeEvent *event) override
React on a resize event.
virtual QSize sizeHint() const override
Recommended size for the widget.
virtual ~ColorWheel() noexcept override
Default destructor.
void hueChanged(const qreal newHue)
Notify signal for property hue.
virtual void keyPressEvent(QKeyEvent *event) override
React on key press events.
The namespace of this library.
Format_ARGB32_Premultiplied
void fill(Qt::GlobalColor color)
void setDevicePixelRatio(qreal scaleFactor)
Q_EMITQ_EMIT
qreal devicePixelRatioF() const const
void drawEllipse(const QPoint &center, int rx, int ry)
void drawImage(const QPoint &point, const QImage &image)
void drawLine(const QLine &line)
void setPen(Qt::PenStyle style)
void setRenderHint(RenderHint hint, bool on)
void setCapStyle(Qt::PenCapStyle style)
void setColor(const QColor &color)
void setWidth(int width)
int x() const const
int y() const const
void setX(qreal x)
void setY(qreal y)
qreal x() const const
qreal y() const const
MouseFocusReason
transparent
Key_Plus
virtual bool event(QEvent *event) override
bool hasFocus() const const
void setFocusPolicy(Qt::FocusPolicy policy)
virtual void keyPressEvent(QKeyEvent *event)
void setFocus()
void update()
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Fri May 17 2024 11:47:51 by doxygen 1.10.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.