Perceptual Color

swatchbook.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 "swatchbook.h"
7// Second, the private implementation.
8#include "swatchbook_p.h" // IWYU pragma: associated
9
10#include "abstractdiagram.h"
11#include "constpropagatingrawpointer.h"
12#include "constpropagatinguniquepointer.h"
13#include "genericcolor.h"
14#include "helper.h"
15#include "helpermath.h"
16#include "initializetranslation.h"
17#include "rgbcolorspace.h"
18#include <algorithm>
19#include <optional>
20#include <qapplication.h>
21#include <qcoreapplication.h>
22#include <qcoreevent.h>
23#include <qevent.h>
24#include <qfontmetrics.h>
25#include <qlabel.h>
26#include <qline.h>
27#include <qlist.h>
28#include <qmargins.h>
29#include <qmenu.h>
30#include <qmetatype.h>
31#include <qnamespace.h>
32#include <qpainter.h>
33#include <qpainterpath.h>
34#include <qpen.h>
35#include <qpoint.h>
36#include <qrect.h>
37#include <qsharedpointer.h>
38#include <qsizepolicy.h>
39#include <qstringliteral.h>
40#include <qstyle.h>
41#include <qstyleoption.h>
42#include <qtransform.h>
43#include <qwidget.h>
44
45#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
46#include <qcontainerfwd.h>
47#else
48#include <qstringlist.h>
49#include <qvector.h>
50#endif
51
52namespace PerceptualColor
53{
54
55/** @brief Retranslate the UI with all user-visible strings.
56 *
57 * This function updates all user-visible strings by using
58 * <tt>Qt::tr()</tt> to get up-to-date translations.
59 *
60 * This function is meant to be called at the end of the constructor and
61 * additionally after each <tt>QEvent::LanguageChange</tt> event.
62 *
63 * @note This is the same concept as
64 * <a href="https://doc.qt.io/qt-5/designer-using-a-ui-file.html">
65 * Qt Designer, which also provides a function of the same name in
66 * uic-generated code</a>. */
67void SwatchBookPrivate::retranslateUi()
68{
69 const QFontMetricsF myFontMetrics(q_pointer->font());
70 auto validateWithFont = [&myFontMetrics](const QString &string) -> QString {
71 // Test if all characters of the translated string are actually
72 // available in the given font.
73 auto ucs4 = string.toUcs4();
74 bool okay = true;
75 for (int i = 0; okay && (i < ucs4.count()); ++i) {
76 okay = myFontMetrics.inFontUcs4(ucs4.at(i));
77 }
78 if (okay) {
79 return string;
80 }
81 return QString();
82 };
83
84 // Which symbol is appropriate as selection mark? This might depend on
85 // culture and language. For more information, see also
86 // https://en.wikipedia.org/w/index.php?title=Check_mark&oldid=1030853305#International_differences
87 // Therefore, we provide translation support for the selection mark.
88
89 // NOTE Some candidates for “translations” of this character might be
90 // emoji characters that might render colorful on some systems and
91 // some fonts. It would be great to disable color fonts and only
92 // accept black fonts. However, this seems to be impossible with Qt.
93 // There is a command-line option named “nocolorfonts”, documented at
94 // https://doc.qt.io/qt-6/qguiapplication.html#QGuiApplication
95 // However, this is only available for DirectWrite font rendering
96 // on Windows. There does not seem to be a cross-platform solution
97 // currently.
98
99 /*: @item Indicate the selected color in the swatch book. This symbol
100 should be translated to whatever symbol is most appropriate for “selected”
101 in the translation language. Example symbols: ✓ U+2713 CHECK MARK.
102 ✗ U+2717 BALLOT X. ✘ U+2718 HEAVY BALLOT X. ○ U+25CB WHITE CIRCLE.
103 ◯ U+25EF LARGE CIRCLE. Do not use emoji characters as they may render
104 colorful on some systems, so they will ignore the automatically chosen
105 color which is used get best contrast with the background. (Also
106 U+FE0E VARIATION SELECTOR-15 does not prevent colorful rendering.) */
107 m_selectionMark = validateWithFont(tr("✓"));
108
109 /*: @item Indicate that you can click on this empty patch to add a new
110 color to it. This symbol should be translated to whatever symbol is most
111 appropriate for “add” in the translation language. Do not use emoji
112 characters as they may render colorful on some systems, so they will ignore
113 the automatically chosen color which is used get best contrast with the
114 background. (Also U+FE0E VARIATION SELECTOR-15 does not prevent colorful
115 rendering.) */
116 m_addMark = validateWithFont(tr("+"));
117
118 // Schedule a paint event to make the changes visible.
119 q_pointer->update();
120}
121
122/** @brief Constructor
123 *
124 * @param colorSpace The color space of the swatches.
125 * @param swatchGrid Initial value for property @ref swatchGrid
126 * @param wideSpacing Set of axis where the spacing should be wider than
127 * normal. This is useful to give some visual structure: When your
128 * swatches are organized logically in columns, set
129 * <tt>Qt::Orientation::Horizontal</tt> here. To use normal spacing
130 * everywhere, simply set this parameter to <tt>{}</tt>.
131 * @param parent The parent of the widget, if any */
132SwatchBook::SwatchBook(const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace,
133 const PerceptualColor::Swatches &swatchGrid,
134 Qt::Orientations wideSpacing,
135 QWidget *parent)
136 : AbstractDiagram(parent)
137 , d_pointer(new SwatchBookPrivate(this, swatchGrid, wideSpacing))
138{
139 qRegisterMetaType<Swatches>();
140
141 d_pointer->m_rgbColorSpace = colorSpace;
142
143 setFocusPolicy(Qt::FocusPolicy::StrongFocus);
144
146
147 // Trigger paint events whenever the mouse enters or leaves the widget.
148 // (Important on some QStyle who might paint widgets different then.)
149 setAttribute(Qt::WA_Hover);
150
151 // Initialize the selection (and implicitly the currentColor property):
152 d_pointer->selectSwatch(8, 0); // Same default as in QColorDialog
153
154 initializeTranslation(QCoreApplication::instance(),
155 // An empty std::optional means: If in initialization
156 // had been done yet, repeat this initialization.
157 // If not, do a new initialization now with default
158 // values.
159 std::optional<QStringList>());
160 d_pointer->retranslateUi();
161
162 d_pointer->updateColorSchemeCache();
163}
164
165/** @brief Destructor */
166SwatchBook::~SwatchBook() noexcept
167{
168}
169
170/** @brief Constructor
171 *
172 * @param backLink Pointer to the object from which <em>this</em> object
173 * is the private implementation.
174 * @param swatchGrid The swatches.
175 * @param wideSpacing Set of axis using @ref widePatchSpacing instead
176 * of @ref normalPatchSpacing. */
177SwatchBookPrivate::SwatchBookPrivate(SwatchBook *backLink, const PerceptualColor::Swatches &swatchGrid, Qt::Orientations wideSpacing)
178 : m_swatchGrid(swatchGrid)
179 , m_wideSpacing(wideSpacing)
180 , q_pointer(backLink)
181{
182}
183
184/** @brief Recommended size for the widget.
185 *
186 * Reimplemented from base class.
187 *
188 * @returns Recommended size for the widget.
189 *
190 * @sa @ref minimumSizeHint() */
191QSize SwatchBook::sizeHint() const
192{
193 return minimumSizeHint();
194}
195
196/** @brief Recommended minimum size for the widget.
197 *
198 * Reimplemented from base class.
199 *
200 * @returns Recommended minimum size for the widget.
201 *
202 * @sa @ref sizeHint() */
203QSize SwatchBook::minimumSizeHint() const
204{
205 ensurePolished();
206 QStyleOptionFrame myOption;
207 d_pointer->initStyleOption(&myOption);
208 const auto contentSize = d_pointer->colorPatchesSizeWithMargin();
209 const auto styleSize = style()->sizeFromContents(QStyle::CT_LineEdit, //
210 &myOption,
211 contentSize,
212 this);
213
214 // On some style like (for example the MacOS style), sizeFromContents()
215 // for line edits returns a value that is less height than the content
216 // size. Therefore, here comes some safety code:
217 const auto lineWidth = myOption.lineWidth;
218 QMargins margins{lineWidth, lineWidth, lineWidth, lineWidth};
219 const QSize minimumSize = contentSize.grownBy(margins);
220
221 return minimumSize.expandedTo(styleSize);
222}
223
224// No documentation here (documentation of properties
225// and its getters are in the header)
226QColor SwatchBook::currentColor() const
227{
228 return d_pointer->m_currentColor;
229}
230
231/** @brief Setter for the @ref currentColor property.
232 *
233 * @param newCurrentColor the new color */
234void SwatchBook::setCurrentColor(const QColor &newCurrentColor)
235{
236 // Convert to RGB:
237 QColor temp = newCurrentColor;
238 if (!temp.isValid()) {
239 temp = Qt::black; // Conformance with QColorDialog
240 }
241 if (temp.spec() != QColor::Spec::Rgb) {
242 // Make sure that the QColor::spec() is QColor::Spec::Rgb.
243 // QColorDialog apparently calls QColor.rgb() within its
244 // setCurrentColor function; this will however round to 8 bit
245 // per channel. We prefer a more exact conversion to RGB:
246 temp = QColor::fromRgbF( //
247 temp.redF(),
248 temp.greenF(),
249 temp.blueF(),
250 temp.alphaF());
251 }
252
253 if (temp == d_pointer->m_currentColor) {
254 return;
255 }
256
257 d_pointer->m_currentColor = temp;
258
259 d_pointer->selectSwatchFromCurrentColor();
260
261 Q_EMIT currentColorChanged(temp);
262
263 update();
264}
265
266// No documentation here (documentation of properties
267// and its getters are in the header)
268Swatches SwatchBook::swatchGrid() const
269{
270 return d_pointer->m_swatchGrid;
271}
272
273/** @brief Setter for the @ref swatchGrid property.
274 *
275 * @param newSwatchGrid the new value */
276void SwatchBook::setSwatchGrid(const PerceptualColor::Swatches &newSwatchGrid)
277{
278 if (newSwatchGrid == d_pointer->m_swatchGrid) {
279 return;
280 }
281
282 d_pointer->m_swatchGrid = newSwatchGrid;
283
284 d_pointer->selectSwatchFromCurrentColor();
285
286 Q_EMIT swatchGridChanged(newSwatchGrid);
287
288 // As of Qt documentation:
289 // “Notifies the layout system that this widget has changed and may
290 // need to change geometry.”
291 updateGeometry();
292
293 update();
294}
295
296/** @brief Selects a swatch from the book.
297 *
298 * @pre Both parameters are valid indexes within @ref m_swatchGrid.
299 * (Otherwise there will likely be a crash.)
300 *
301 * @param newCurrentColomn Index of the column.
302 *
303 * @param newCurrentRow Index of the row.
304 *
305 * @post If the given swatch is empty, nothing happens. Otherwise, it is
306 * selected and the selection mark is visible and @ref SwatchBook::currentColor
307 * has the value of this color. */
308void SwatchBookPrivate::selectSwatch(QListSizeType newCurrentColomn, QListSizeType newCurrentRow)
309{
310 const auto newColor = m_swatchGrid.value(newCurrentColomn, newCurrentRow);
311 if (!newColor.isValid()) {
312 return;
313 }
314 m_selectedColumn = newCurrentColomn;
315 m_selectedRow = newCurrentRow;
316 if (newColor != m_currentColor) {
317 m_currentColor = newColor;
318 Q_EMIT q_pointer->currentColorChanged(newColor);
319 }
320 q_pointer->update();
321}
322
323/** @brief Selects a swatch from the grid.
324 *
325 * @post If the currently selected swatch corresponds to
326 * @ref SwatchBook::currentColor nothing happens. Otherwise, a swatch if
327 * selected if there is one that corresponds to @ref SwatchBook::currentColor,
328 * or none if there is no corresponding swatch. */
329void SwatchBookPrivate::selectSwatchFromCurrentColor()
330{
331 if ((m_selectedColumn > 0) && (m_selectedRow > 0)) {
332 if (m_swatchGrid.value(m_selectedColumn, m_selectedRow) == m_currentColor) {
333 return;
334 }
335 }
336
337 bool colorFound = false;
338 const qsizetype myColumnCount = m_swatchGrid.iCount();
339 const qsizetype myRowCount = m_swatchGrid.jCount();
340 int columnIndex = 0;
341 int rowIndex = 0;
342 for (columnIndex = 0; //
343 columnIndex < myColumnCount; //
344 ++columnIndex) {
345 for (rowIndex = 0; rowIndex < myRowCount; ++rowIndex) {
346 if (m_swatchGrid.value(columnIndex, rowIndex) == m_currentColor) {
347 colorFound = true;
348 break;
349 }
350 }
351 if (colorFound) {
352 break;
353 }
354 }
355 if (colorFound) {
356 m_selectedColumn = columnIndex;
357 m_selectedRow = rowIndex;
358 } else {
359 m_selectedColumn = -1;
360 m_selectedRow = -1;
361 }
362}
363
364/** @brief Horizontal spacing between color patches.
365 *
366 * @returns Horizontal spacing between color patches, measured in
367 * device-independent pixel. The value depends on the
368 * current <tt>QStyle</tt>.
369 *
370 * @sa @ref verticalPatchSpacing */
371int SwatchBookPrivate::horizontalPatchSpacing() const
372{
373 if (m_wideSpacing.testFlag(Qt::Orientation::Horizontal)) {
374 return widePatchSpacing();
375 }
376 return normalPatchSpacing();
377}
378
379/** @brief Value for a wide spacing between swatches.
380 *
381 * @returns Wide spacing between color patches, measured in
382 * device-independent pixel. The value depends on the
383 * current <tt>QStyle</tt>.
384 *
385 * @sa @ref normalPatchSpacing */
386int SwatchBookPrivate::widePatchSpacing() const
387{
388 // NOTE The value is derived from the current QStyle’s values for some
389 // horizontal spacings. This seems reasonable because some styles might
390 // have tighter metrics for vertical spacing, which might not look good
391 // here. The derived value is actually useful for both, horizontal and
392 // vertical metrics.
393
394 int temp = q_pointer->style()->pixelMetric( //
396 nullptr,
397 q_pointer.toPointerToConstObject());
398 if (temp <= 0) {
399 // Some styles like Qt’s build-in “Plastique” style or the external
400 // “QtCurve” style return 0 here. If so, we fall back to the left
401 // margin. (We do not use qMax() because this workaround should
402 // really only apply when the returned value is 0, because under
403 // normal circumstances, it might be intentional that the left
404 // margin is bigger than the horizontal spacing.)
405 temp = q_pointer->style()->pixelMetric( //
407 nullptr,
408 q_pointer.toPointerToConstObject());
409 }
410 // Another fallback (if also PM_LayoutLeftMargin fails):
411 if (temp <= 0) {
412 temp = q_pointer->style()->pixelMetric( //
414 nullptr,
415 q_pointer.toPointerToConstObject());
416 }
417 // A last-resort fallback:
418 return qMax(temp, 2);
419}
420
421/** @brief Vertical spacing between color patches.
422 *
423 * @returns Vertical spacing between color patches, measured in
424 * device-independent pixel. The value is typically smaller than
425 * @ref horizontalPatchSpacing(), to symbolize that the binding
426 * between patches is vertically stronger than horizontally. */
427int SwatchBookPrivate::verticalPatchSpacing() const
428{
429 if (m_wideSpacing.testFlag(Qt::Orientation::Vertical)) {
430 return widePatchSpacing();
431 }
432 return normalPatchSpacing();
433}
434
435/** @brief Normal spacing between color swatches.
436 *
437 * @returns Normal spacing between color patches, measured in
438 * device-independent pixel. The value is typically smaller than
439 * @ref widePatchSpacing(), to symbolize that the binding
440 * between patches is stronger. */
441int SwatchBookPrivate::normalPatchSpacing() const
442{
443 return qMax(widePatchSpacing() / 3, // ⅓ looks nice
444 1 // minimal useful value for a line visible as all scales
445 );
446}
447
448/** @brief Initializes a <tt>QStyleOptionFrame</tt> object for this widget
449 * in its current state.
450 *
451 * This function is provided analogous to many Qt widgets that also
452 * provide a function of that name with this purpose.
453 *
454 * @param option The object that will be initialized
455 *
456 * @note The value in QStyleOptionFrame::rect is not initialized. */
457void SwatchBookPrivate::initStyleOption(QStyleOptionFrame *option) const
458{
459 if (option == nullptr) {
460 return;
461 }
462 option->initFrom(q_pointer.toPointerToConstObject());
463 option->lineWidth = q_pointer->style()->pixelMetric( //
465 option,
466 q_pointer.toPointerToConstObject());
467 option->midLineWidth = 0;
468 option->state |= QStyle::State_Sunken;
469 // The following option is not set because this widget
470 // currently has no read-only mode:
471 // option->state |= QStyle::State_ReadOnly;
472 option->features = QStyleOptionFrame::None;
473}
474
475/** @brief Offset between top-left of the widget and top-left of the content.
476 *
477 * @param styleOptionFrame The options that will be passed to <tt>QStyle</tt>
478 * to calculate correctly the offset.
479 *
480 * @returns The pixel position of the top-left pixel of the content area
481 * which can be used for the color patches. */
482QPoint SwatchBookPrivate::offset(const QStyleOptionFrame &styleOptionFrame) const
483{
484 const QPoint innerMarginOffset = QPoint( //
485 q_pointer->style()->pixelMetric(QStyle::PM_LayoutLeftMargin),
486 q_pointer->style()->pixelMetric(QStyle::PM_LayoutTopMargin));
487
488 QStyleOptionFrame temp = styleOptionFrame; // safety copy
489 const QRectF frameContentRectangle = q_pointer->style()->subElementRect( //
491 &temp, // Risk of changes, therefore using the safety copy
492 q_pointer.toPointerToConstObject());
493 const QSizeF swatchbookContentSize = colorPatchesSizeWithMargin();
494
495 // Some styles, such as the Fusion style, regularly return a slightly
496 // larger rectangle through QStyle::subElementRect() than the one
497 // requested in SwatchBook::minimumSizeHint(), which we need to draw
498 // the color patches. In the case of the Kvantum style,
499 // QStyle::subElementRect().height() is even greater than
500 // SwatchBook::height(). It extends beyond the widget's own dimensions,
501 // both at the top and bottom. QStyle::subElementRect().y() is
502 // negative. Please see https://github.com/tsujan/Kvantum/issues/676
503 // for more information. To ensure a visually pleasing rendering, we
504 // implement centering within QStyle::subElementRect().
505 QPointF frameOffset = frameContentRectangle.center();
506 frameOffset.rx() -= swatchbookContentSize.width() / 2.;
507 frameOffset.ry() -= swatchbookContentSize.height() / 2.;
508
509 return (frameOffset + innerMarginOffset).toPoint();
510}
511
512/**
513 * @brief Calculates the logical column and row based on a position relative to
514 * the widget.
515 *
516 * The logical column and row may differ from the visual column and row
517 * in environments with right-to-left text direction, where color patches are
518 * mirrored.
519 *
520 * @param position The position in logical pixels relative to the widget.
521 *
522 * @returns The logical column and row corresponding to the given position,
523 * or <tt>(-1, -1)</tt> if the position does not correspond to any color patch.
524 * Empty color patches are treated as active color patches.
525 */
526std::pair<QListSizeType, QListSizeType> SwatchBookPrivate::logicalColumnRowFromPosition(const QPoint position) const
527{
528 constexpr std::pair<QListSizeType, QListSizeType> invalid(-1, -1);
529
530 const QSize myColorPatchSize = patchSizeOuter();
531 const int myPatchWidth = myColorPatchSize.width();
532 const int myPatchHeight = myColorPatchSize.height();
533 QStyleOptionFrame myFrameStyleOption;
534 initStyleOption(&myFrameStyleOption);
535 const QPoint temp = position - offset(myFrameStyleOption);
536
537 if ((temp.x() < 0) || (temp.y() < 0)) {
538 return invalid; // Click position too much leftwards or upwards.
539 }
540
541 const auto columnWidth = myPatchWidth + horizontalPatchSpacing();
542 const int xWithinPatch = temp.x() % columnWidth;
543 if (xWithinPatch >= myPatchWidth) {
544 return invalid; // Click position horizontally between two patch columns
545 }
546
547 const auto rowHeight = myPatchHeight + verticalPatchSpacing();
548 const int yWithinPatch = temp.y() % rowHeight;
549 if (yWithinPatch >= myPatchHeight) {
550 return invalid; // Click position vertically between two patch rows
551 }
552
553 const int rowIndex = temp.y() / rowHeight;
554 if (!isInRange<qsizetype>(0, rowIndex, m_swatchGrid.jCount() - 1)) {
555 // The index is out of range. This might happen when the user
556 // clicks very near to the border, where is no other patch
557 // anymore, but which is still part of the widget.
558 return invalid;
559 }
560
561 const int visualColumnIndex = temp.x() / columnWidth;
562 QListSizeType columnIndex;
563 if (q_pointer->layoutDirection() == Qt::LayoutDirection::LeftToRight) {
564 columnIndex = visualColumnIndex;
565 } else {
566 columnIndex = m_swatchGrid.iCount() - 1 - visualColumnIndex;
567 }
568 if (!isInRange<qsizetype>(0, columnIndex, m_swatchGrid.iCount() - 1)) {
569 // The index is out of range. This might happen when the user
570 // clicks very near to the border, where is no other patch
571 // anymore, but which is still part of the widget.
572 return invalid;
573 }
574
575 return std::pair<QListSizeType, QListSizeType>(columnIndex, rowIndex);
576}
577
578/** @brief React on a mouse press event.
579 *
580 * Reimplemented from base class.
581 *
582 * @param event The corresponding mouse event */
583void SwatchBook::mousePressEvent(QMouseEvent *event)
584{
585 // NOTE We will not actively ignore the event, even if we didn’t actually
586 // react on it. Therefore, Breeze and other styles cannot move
587 // the window when clicking in the middle between two patches.
588 // This is intentional, because allowing it would be confusing:
589 // - The space between the patches is quite limited anyway, so
590 // it’s not worth the pain and could be surprising because somebody
591 // can click there by mistake.
592 // - We use the same background as QLineEdit, which at its turn also
593 // does not allow moving the window with a left-click within the
594 // field. We should be consistent with this behaviour.
595
596 const auto logicalColumnRow = d_pointer->logicalColumnRowFromPosition(event->pos());
597 const auto logicalColumn = logicalColumnRow.first;
598 const auto logicalRow = logicalColumnRow.second;
599 if ((logicalColumn < 0) || (logicalRow < 0)) {
600 return;
601 }
602
603 // If we reached here, the click must have been within a patch
604 // and we have valid indexes.
605
606 const bool swatchIsEmpty = //
607 !d_pointer->m_swatchGrid.value(logicalColumn, logicalRow).isValid();
608
609 if (event->button() == Qt::MouseButton::RightButton) {
610 if (!swatchIsEmpty) {
611 QMenu *menu = new QMenu(this);
612 // Ensure proper cleanup when menu is closed
614 /*: action:inmenu Appears in the context menu of swatches within
615 the swatch book and provides the option to remove a swatch from the
616 swatch book. */
617 QAction *deleteAction = menu->addAction(tr("Delete"));
618 connect(deleteAction, //
620 this, //
621 [this, logicalColumn, logicalRow]() {
622 d_pointer->m_swatchGrid.setValue(logicalColumn, //
623 logicalRow, //
624 QColor());
625 // If the deleted swatch was the currently selected
626 // swatch, the selection mark needs an update:
627 d_pointer->selectSwatchFromCurrentColor();
628 Q_EMIT swatchGridChanged(d_pointer->m_swatchGrid);
629 });
630 menu->popup(mapToGlobal(event->pos())); // Display asynchronously
631 return;
632 }
633 return;
634 }
635
636 if (event->button() == Qt::MouseButton::LeftButton) {
637 if (swatchIsEmpty) {
638 d_pointer->m_swatchGrid.setValue(logicalColumn, //
639 logicalRow, //
640 d_pointer->m_currentColor);
641 d_pointer->selectSwatch(logicalColumn, logicalRow);
642 Q_EMIT swatchGridChanged(d_pointer->m_swatchGrid);
643 } else {
644 d_pointer->selectSwatch(logicalColumn, logicalRow);
645 }
646 return;
647 }
648}
649
650/** @brief The size of the color patches.
651 *
652 * This is the bounding box around the outer limit.
653 *
654 * @returns The size of the color patches, measured in device-independent
655 * pixel.
656 *
657 * @sa @ref patchSizeInner */
658QSize SwatchBookPrivate::patchSizeOuter() const
659{
660 q_pointer->ensurePolished();
661 const QSize myInnerSize = patchSizeInner();
662 QStyleOptionToolButton myOptions;
663 myOptions.initFrom(q_pointer.toPointerToConstObject());
664 myOptions.rect.setSize(myInnerSize);
665 const auto myStyledOuterSize = q_pointer->style()->sizeFromContents( //
667 &myOptions,
668 myInnerSize,
669 q_pointer.toPointerToConstObject());
670 // Ensure that the difference between patchInnerSize and patchOuterSize is
671 // large enough to accommodate twice the cornerRadius. This accounts for
672 // one cornerRadius on each border of the rectangle.
673 const int extra = 2 * cornerRadius();
674 return myStyledOuterSize.expandedTo(myInnerSize + QSize(extra, extra));
675}
676
677/** @brief Size of the inner space of a color patch.
678 *
679 * This is typically smaller than @ref patchSizeOuter().
680 *
681 * @returns Size of the inner space of a color patch, measured in
682 * device-independent pixel. */
683QSize SwatchBookPrivate::patchSizeInner() const
684{
685 const int metric = q_pointer->style()->pixelMetric( //
687 nullptr,
688 q_pointer.toPointerToConstObject());
689 const int size = std::max({metric, //
690 horizontalPatchSpacing(), //
691 verticalPatchSpacing()});
692 return QSize(size, size);
693}
694
695/** @brief Corner radius for drawing rounded color patch rectangles
696 *
697 * Tries to guess a radius that matches well with the current QStyle.
698 *
699 * @returns Corner radius for drawing rounded color patch rectangles.
700 * Guarantied to be ≥ 0. */
701int SwatchBookPrivate::cornerRadius() const
702{
703 const auto defaultFrameWidth = //
704 q_pointer->style()->pixelMetric(QStyle::PM_DefaultFrameWidth);
705 return qMax(defaultFrameWidth, 0);
706}
707
708/**
709 * @brief Draws some mark somewhere on a paint surface.
710 *
711 * Meant for @ref SwatchBook::paintEvent()
712 *
713 * @param offset Offset from the origin of the coordinate system
714 * @param widgetPainter Pointer to a painter that will paint on the surface.
715 * The state of the painter will be saved before modifying it, and
716 * restored before this function returns.
717 * @param color Color used to draw the mark
718 * @param markSymbol Which mark to draw.
719 * @param row in @ref m_swatchGrid
720 * @param column in @ref m_swatchGrid
721 */
722void SwatchBookPrivate::drawMark(const QPoint offset,
723 QPainter *widgetPainter,
724 const QColor color,
725 const SwatchBookPrivate::Mark markSymbol,
726 const QListSizeType row,
727 const QListSizeType column) const
728{
729 widgetPainter->save(); // We'll do a restore() at the end of this function.
730
731 // Draw the selection mark (if any)
732 const QListSizeType visualSelectedColumnIndex = //
733 (q_pointer->layoutDirection() == Qt::LayoutDirection::LeftToRight) //
734 ? column //
735 : m_swatchGrid.iCount() - 1 - column;
736 const int patchWidthOuter = patchSizeOuter().width();
737 const int patchHeightOuter = patchSizeOuter().height();
738
739 const QPointF selectedPatchOffset = QPointF( //
740 offset.x() //
741 + (static_cast<int>(visualSelectedColumnIndex) //
742 * (patchWidthOuter + horizontalPatchSpacing())), //
743 offset.y() //
744 + (static_cast<int>(row) //
745 * (patchHeightOuter + verticalPatchSpacing())));
746 const int patchWidthInner = patchSizeInner().width();
747 const int patchHeightInner = patchSizeInner().height();
748
749 QString myMark;
750 switch (markSymbol) {
751 case Mark::Selection:
752 myMark = m_selectionMark;
753 break;
754 case Mark::Add:
755 myMark = m_addMark;
756 break;
757 default:
758 break;
759 }
760 if (myMark.isEmpty()) {
761 // If no selection mark is available for the current translation in
762 // the current font, we will draw a hard-coded fallback mark.
763 const QSize sizeDifference = patchSizeOuter() - patchSizeInner();
764 // Offset of the selection mark to the border of the patch:
765 QPointF selectionMarkOffset = QPointF( //
766 sizeDifference.width() / 2.0,
767 sizeDifference.height() / 2.0);
768 if (patchWidthInner > patchHeightInner) {
769 selectionMarkOffset.rx() += //
770 ((patchWidthInner - patchHeightInner) / 2.0);
771 }
772 if (patchHeightInner > patchWidthInner) {
773 selectionMarkOffset.ry() += //
774 ((patchHeightInner - patchWidthInner) / 2.0);
775 }
776 const int effectiveSquareSize = qMin( //
777 patchHeightInner,
778 patchWidthInner);
779 qreal penWidth = effectiveSquareSize * 0.08;
780 QPen pen;
781 pen.setColor(color);
782 pen.setCapStyle(Qt::PenCapStyle::RoundCap);
783 pen.setWidthF(penWidth);
784 widgetPainter->setPen(pen);
785
786 switch (markSymbol) {
787 case Mark::Selection: {
788 QPointF point1 = QPointF(penWidth, //
789 0.7 * effectiveSquareSize);
790 point1 += selectedPatchOffset + selectionMarkOffset;
791 QPointF point2(0.35 * effectiveSquareSize, //
792 1 * effectiveSquareSize - penWidth);
793 point2 += selectedPatchOffset + selectionMarkOffset;
794 QPointF point3(1 * effectiveSquareSize - penWidth, //
795 penWidth);
796 point3 += selectedPatchOffset + selectionMarkOffset;
797 widgetPainter->drawLine(QLineF(point1, point2));
798 widgetPainter->drawLine(QLineF(point2, point3));
799 } break;
800 case Mark::Add: {
801 QPointF point1 = QPointF(penWidth, //
802 0.5 * effectiveSquareSize);
803 point1 += selectedPatchOffset + selectionMarkOffset;
804 QPointF point2 = QPointF(1 * effectiveSquareSize - penWidth, //
805 0.5 * effectiveSquareSize);
806 point2 += selectedPatchOffset + selectionMarkOffset;
807 QPointF point3(0.5 * effectiveSquareSize, //
808 penWidth);
809 point3 += selectedPatchOffset + selectionMarkOffset;
810 QPointF point4(0.5 * effectiveSquareSize, //
811 1 * effectiveSquareSize - penWidth);
812 point4 += selectedPatchOffset + selectionMarkOffset;
813 widgetPainter->drawLine(QLineF(point1, point2));
814 widgetPainter->drawLine(QLineF(point3, point4));
815 } break;
816 default:
817 break;
818 }
819 } else {
820 QPainterPath textPath;
821 // Render the selection mark string in the path
822 textPath.addText(0, 0, q_pointer->font(), myMark);
823 // Align the path top-left to the path’s virtual coordinate system
824 textPath.translate(textPath.boundingRect().x() * (-1), //
825 textPath.boundingRect().y() * (-1));
826 // QPainterPath::boundingRect() might be slow. Cache the result:
827 const QSizeF boundingRectangleSize = textPath.boundingRect().size();
828
829 if (!boundingRectangleSize.isEmpty()) { // Prevent division by 0
830 QTransform textTransform;
831
832 // Offset for the current patch
833 textTransform.translate(
834 // x:
835 selectedPatchOffset.x() //
836 + (patchWidthOuter - patchWidthInner) / 2,
837 // y:
838 selectedPatchOffset.y() //
839 + (patchHeightOuter - patchHeightInner) / 2);
840
841 // Scale to maximum and center within the margins
842 const qreal scaleFactor = qMin(
843 // Horizontal scale factor:
844 patchWidthInner / boundingRectangleSize.width(),
845 // Vertical scale factor:
846 patchHeightInner / boundingRectangleSize.height());
847 QSizeF scaledSelectionMarkSize = //
848 boundingRectangleSize * scaleFactor;
849 const QSizeF temp = //
850 (patchSizeInner() - scaledSelectionMarkSize) / 2;
851 textTransform.translate(temp.width(), temp.height());
852 textTransform.scale(scaleFactor, scaleFactor);
853
854 // Draw
855 widgetPainter->setTransform(textTransform);
856 widgetPainter->setPen(Qt::NoPen);
857 widgetPainter->setBrush(color);
858 widgetPainter->drawPath(textPath);
859 }
860 }
861
862 widgetPainter->restore(); // restore from initial save() function.
863}
864
865/** @brief Paint the widget.
866 *
867 * Reimplemented from base class.
868 *
869 * @param event the paint event */
870void SwatchBook::paintEvent(QPaintEvent *event)
871{
872 Q_UNUSED(event)
873
874 // Initialization
875 QPainter widgetPainter(this);
877 QStyleOptionFrame frameStyleOption;
878 d_pointer->initStyleOption(&frameStyleOption);
879 const int patchWidthOuter = d_pointer->patchSizeOuter().width();
880 const int patchHeightOuter = d_pointer->patchSizeOuter().height();
881
882 // Draw the background
883 {
884 // We draw the frame slightly shrunk on windowsvista style. Otherwise,
885 // when the windowsvista style is used on 125% scale factor and with
886 // a multi-monitor setup, the frame would sometimes not render on some
887 // of the borders on some of the screens.
888 const bool vistaStyle = QString::compare( //
889 QApplication::style()->objectName(),
890 QStringLiteral("windowsvista"),
892 == 0;
893 const int shrink = vistaStyle ? 1 : 0;
894 const QMargins margins(shrink, shrink, shrink, shrink);
895 auto shrunkFrameStyleOption = frameStyleOption;
896 shrunkFrameStyleOption.rect = frameStyleOption.rect - margins;
897 // NOTE On ukui style, drawing this primitive results in strange
898 // rendering on mouse hover. Actual behaviour: The whole panel
899 // background is drawn blue. Expected behaviour: Only the frame is
900 // drawn blue (as ukui actually does when rendering a QLineEdit).
901 // Playing around with PE_FrameLineEdit instead of or additional to
902 // PE_PanelLineEdit did not give better results either.
903 // As ukui has many, many graphical glitches and bugs (up to crashs not
904 // having been fixed for years), we assume that this is a problem of
905 // ukui, and not of our code. Furthermore, while the behavior is
906 // unexpected, the rendering doesn’t look completely terrible; we
907 // can live with that.
908 style()->drawPrimitive(QStyle::PE_PanelLineEdit, //
909 &shrunkFrameStyleOption,
910 &widgetPainter,
911 this);
912 }
913
914 // Draw the color patches
915 const QPoint offset = d_pointer->offset(frameStyleOption);
916 const QListSizeType columnCount = d_pointer->m_swatchGrid.iCount();
917 const int myCornerRadius = d_pointer->cornerRadius();
918 QListSizeType visualColumn;
919 const auto currentScheme = d_pointer->m_colorSchemeCache;
920 const QColor addMarkColor = (currentScheme == ColorSchemeType::Dark) //
921 ? Qt::white
922 : Qt::black;
923 for (int columnIndex = 0; columnIndex < columnCount; ++columnIndex) {
924 for (int row = 0; //
925 row < d_pointer->m_swatchGrid.jCount(); //
926 ++row //
927 ) {
928 const auto swatchColor = //
929 d_pointer->m_swatchGrid.value(columnIndex, row);
930 if (swatchColor.isValid()) {
931 widgetPainter.setBrush(swatchColor);
932 widgetPainter.setPen(Qt::NoPen);
933 if (layoutDirection() == Qt::LayoutDirection::LeftToRight) {
934 visualColumn = columnIndex;
935 } else {
936 visualColumn = columnCount - 1 - columnIndex;
937 }
938 widgetPainter.drawRoundedRect( //
939 offset.x() //
940 + (static_cast<int>(visualColumn) //
941 * (patchWidthOuter + d_pointer->horizontalPatchSpacing())),
942 offset.y() //
943 + row * (patchHeightOuter + d_pointer->verticalPatchSpacing()),
944 patchWidthOuter,
945 patchHeightOuter,
946 myCornerRadius,
947 myCornerRadius);
948 } else {
949 if (d_pointer->m_editable) {
950 d_pointer->drawMark(offset, //
951 &widgetPainter, //
952 addMarkColor, //
953 SwatchBookPrivate::Mark::Add, //
954 row, //
955 columnIndex);
956 }
957 }
958 }
959 }
960
961 // If there is no selection mark to draw, nothing more to do: Return!
962 if (d_pointer->m_selectedColumn < 0 || d_pointer->m_selectedRow < 0) {
963 return;
964 }
965
966 // Draw the selection mark (if any)
967 const auto selectedColor = d_pointer->m_swatchGrid.value( //
968 d_pointer->m_selectedColumn,
969 d_pointer->m_selectedRow);
970 // TODO Use Oklab instead of CielchD50
971 const auto colorCielchD50 = d_pointer->m_rgbColorSpace->toCielchD50( //
972 selectedColor.rgba64());
973 const QColor selectionMarkColor = //
974 handleColorFromBackgroundLightness(colorCielchD50.first);
975 d_pointer->drawMark(offset, //
976 &widgetPainter, //
977 selectionMarkColor, //
978 SwatchBookPrivate::Mark::Selection, //
979 d_pointer->m_selectedRow, //
980 d_pointer->m_selectedColumn);
981}
982
983/** @brief React on key press events.
984 *
985 * Reimplemented from base class.
986 *
987 * When the arrow keys are pressed, it moves the selection mark
988 * into the desired direction.
989 * When <tt>Qt::Key_PageUp</tt>, <tt>Qt::Key_PageDown</tt>,
990 * <tt>Qt::Key_Home</tt> or <tt>Qt::Key_End</tt> are pressed, it moves the
991 * handle a big step into the desired direction.
992 *
993 * Other key events are forwarded to the base class.
994 *
995 * @param event the event */
996void SwatchBook::keyPressEvent(QKeyEvent *event)
997{
998 QListSizeType columnShift = 0;
999 QListSizeType rowShift = 0;
1000 const int writingDirection = //
1001 (layoutDirection() == Qt::LeftToRight) //
1002 ? 1 //
1003 : -1;
1004 switch (event->key()) {
1005 case Qt::Key_Up:
1006 rowShift = -1;
1007 break;
1008 case Qt::Key_Down:
1009 rowShift = 1;
1010 break;
1011 case Qt::Key_Left:
1012 columnShift = -1 * writingDirection;
1013 break;
1014 case Qt::Key_Right:
1015 columnShift = 1 * writingDirection;
1016 break;
1017 case Qt::Key_PageUp:
1018 rowShift = (-1) * d_pointer->m_swatchGrid.jCount();
1019 break;
1020 case Qt::Key_PageDown:
1021 rowShift = d_pointer->m_swatchGrid.jCount();
1022 break;
1023 case Qt::Key_Home:
1024 columnShift = (-1) * d_pointer->m_swatchGrid.iCount();
1025 break;
1026 case Qt::Key_End:
1027 columnShift = d_pointer->m_swatchGrid.iCount();
1028 break;
1029 default:
1030 // Quote from Qt documentation:
1031 //
1032 // “If you reimplement this handler, it is very important that
1033 // you call the base class implementation if you do not act
1034 // upon the key.
1035 //
1036 // The default implementation closes popup widgets if the
1037 // user presses the key sequence for QKeySequence::Cancel
1038 // (typically the Escape key). Otherwise the event is
1039 // ignored, so that the widget’s parent can interpret it.“
1041 return;
1042 }
1043 // Here we reach only if the key has been recognized. If not, in the
1044 // default branch of the switch statement, we would have passed the
1045 // keyPressEvent yet to the parent and returned.
1046
1047 // If currently no color of the swatch book is selected, select the
1048 // first color as default.
1049 if ((d_pointer->m_selectedColumn < 0) && (d_pointer->m_selectedRow < 0)) {
1050 d_pointer->selectSwatch(0, 0);
1051 return;
1052 }
1053
1054 const int accelerationFactor = 2;
1055 if (event->modifiers().testFlag(Qt::ControlModifier)) {
1056 columnShift *= accelerationFactor;
1057 rowShift *= accelerationFactor;
1058 }
1059
1060 d_pointer->selectSwatch( //
1061 qBound<QListSizeType>(0, //
1062 d_pointer->m_selectedColumn + columnShift,
1063 d_pointer->m_swatchGrid.iCount() - 1),
1064 qBound<QListSizeType>(0, //
1065 d_pointer->m_selectedRow + rowShift, //
1066 d_pointer->m_swatchGrid.jCount() - 1));
1067}
1068
1069/** @brief Handle state changes.
1070 *
1071 * Implements reaction on <tt>QEvent::LanguageChange</tt>.
1072 *
1073 * Reimplemented from base class.
1074 *
1075 * @param event The event. */
1076void SwatchBook::changeEvent(QEvent *event)
1077{
1078 const auto type = event->type();
1079
1080 if (type == QEvent::LanguageChange) {
1081 // From QCoreApplication documentation:
1082 // “Installing or removing a QTranslator, or changing an installed
1083 // QTranslator generates a LanguageChange event for the
1084 // QCoreApplication instance. A QApplication instance will
1085 // propagate the event to all toplevel widgets […].
1086 // Retranslate this widget itself:
1087 d_pointer->retranslateUi();
1088 }
1089
1090 if ((type == QEvent::PaletteChange) || (type == QEvent::StyleChange)) {
1091 d_pointer->updateColorSchemeCache();
1092 update();
1093 }
1094
1095 QWidget::changeEvent(event);
1096}
1097
1098/**
1099 * @brief Updates @ref m_colorSchemeCache
1100 */
1101void SwatchBookPrivate::updateColorSchemeCache()
1102{
1103 m_colorSchemeCache = guessColorSchemeTypeFromWidget(q_pointer);
1104}
1105
1106/** @brief Size necessary to render the color patches, including a margin.
1107 *
1108 * @returns Size necessary to render the color patches, including a margin.
1109 * Measured in device-independent pixels. */
1110QSize SwatchBookPrivate::colorPatchesSizeWithMargin() const
1111{
1112 q_pointer->ensurePolished();
1113 const QSize patchSize = patchSizeOuter();
1114 const int columnCount = static_cast<int>(m_swatchGrid.iCount());
1115 const int rowCount = static_cast<int>(m_swatchGrid.jCount());
1116 const int width = //
1117 q_pointer->style()->pixelMetric(QStyle::PM_LayoutLeftMargin) //
1118 + columnCount * patchSize.width() //
1119 + (columnCount - 1) * horizontalPatchSpacing() //
1120 + q_pointer->style()->pixelMetric(QStyle::PM_LayoutRightMargin);
1121 const int height = //
1122 q_pointer->style()->pixelMetric(QStyle::PM_LayoutTopMargin) //
1123 + rowCount * patchSize.height() //
1124 + (rowCount - 1) * verticalPatchSpacing() //
1125 + q_pointer->style()->pixelMetric(QStyle::PM_LayoutBottomMargin);
1126 return QSize(width, height);
1127}
1128
1129bool SwatchBook::isEditable() const
1130{
1131 return d_pointer->m_editable;
1132}
1133
1134/** @brief Setter for the @ref editable property.
1135 *
1136 * @param newEditable the new value */
1137void SwatchBook::setEditable(const bool newEditable)
1138{
1139 d_pointer->m_editable = newEditable;
1140 update(); // Schedule a paint event to make the changes visible.
1141 Q_EMIT editableChanged(newEditable);
1142}
1143
1144} // namespace PerceptualColor
Base class for LCH diagrams.
AKONADI_CALENDAR_EXPORT KCalendarCore::Event::Ptr event(const Akonadi::Item &item)
Type type(const QSqlDatabase &db)
void update(Part *part, const QByteArray &data, qint64 dataSize)
The namespace of this library.
Array2D< QColor > Swatches
Swatches organized in a grid.
Definition helper.h:262
void triggered(bool checked)
QStyle * style()
float alphaF() const const
float blueF() const const
QColor fromRgbF(float r, float g, float b, float a)
float greenF() const const
bool isValid() const const
float redF() const const
Spec spec() const const
QCoreApplication * instance()
QAction * addAction(const QIcon &icon, const QString &text, Functor functor, const QKeySequence &shortcut)
void popup(const QPoint &p, QAction *atAction)
QString tr(const char *sourceText, const char *disambiguation, int n)
void drawLine(const QLine &line)
void drawPath(const QPainterPath &path)
void drawRoundedRect(const QRect &rect, qreal xRadius, qreal yRadius, Qt::SizeMode mode)
void restore()
void save()
void setBrush(Qt::BrushStyle style)
void setPen(Qt::PenStyle style)
void setRenderHint(RenderHint hint, bool on)
void setTransform(const QTransform &transform, bool combine)
void addText(const QPointF &point, const QFont &font, const QString &text)
QRectF boundingRect() const const
void translate(const QPointF &offset)
void setCapStyle(Qt::PenCapStyle style)
void setColor(const QColor &color)
void setWidthF(qreal width)
int x() const const
int y() const const
qreal & rx()
qreal & ry()
qreal x() const const
qreal y() const const
QPointF center() const const
QSizeF size() const const
qreal x() const const
qreal y() const const
QSize expandedTo(const QSize &otherSize) const const
QSize grownBy(QMargins margins) const const
int height() const const
int width() const const
qreal height() const const
bool isEmpty() const const
qreal width() const const
int compare(QLatin1StringView s1, const QString &s2, Qt::CaseSensitivity cs)
bool isEmpty() const const
PM_LayoutHorizontalSpacing
PE_PanelLineEdit
SE_LineEditContents
void initFrom(const QWidget *widget)
CaseInsensitive
ControlModifier
LeftToRight
typedef Orientations
WA_Hover
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
QTransform & scale(qreal sx, qreal sy)
QTransform & translate(qreal dx, qreal dy)
virtual void changeEvent(QEvent *event)
virtual void keyPressEvent(QKeyEvent *event)
void setAttribute(Qt::WidgetAttribute attribute, bool on)
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Apr 25 2025 12:03:13 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.