#include "HueSaturationSheetFixtures.h" // Swift's HueSaturationSheet: its controls, bindings and states. class HueSaturationSheetTests : public QObject { Q_OBJECT private slots: void theSheetIsLaidOutAsSwifts(); void aRangeShowsItsSpectrumAndSamplers(); void slidersAndFieldsWriteTheirValue(); void fieldsClampOnReturnAndPrintWhole(); void samplersAndTargetingToggle(); void theControlsFollowTheSession(); void togglesResetAndTheButtons(); void tracksFollowTheRangeAndColorize(); void aDoubleClickResetsToNoneOrColorizesStart(); void aResetReplacesTheFieldsTyping(); void arrowKeysWalkEverySlider(); void aResetKeepsPreviewOffInBothModes(); void aRangeUnderColorizeKeepsItsTracks(); void aTitleResetsOnALeftDoubleClickThenScrubs(); }; void HueSaturationSheetTests::theSheetIsLaidOutAsSwifts() { Sheet shown; QVERIFY(shown.show()); QCOMPARE(shown.sheet->width(), 460); QCOMPARE(shown.sheet->layout()->contentsMargins(), QMargins(24, 24, 24, 24)); QCOMPARE(shown.sheet->layout()->spacing(), 16); QComboBox &range = shown.child("hueRange"); QCOMPARE(range.count(), 7); QCOMPARE(range.itemText(0), QString("Master")); QCOMPARE(range.itemText(6), QString("Magentas")); QCOMPARE(range.width(), 160); // Swift hides the picker's label; it still names it. QCOMPARE(labelled(range), QString("Range")); QVERIFY(shown.titled(QStringLiteral("Range")).isHidden()); QVERIFY(range.isEnabled()); // Master: no spectrum, no eyedroppers; targeting stays. for (const char *sampler : {"hueSample", "hueAdd", "hueRemove"}) QVERIFY2(shown.child(sampler).isHidden(), sampler); QVERIFY(!shown.child("hueTargeting").isHidden()); QVERIFY(shown.child("hueSpectrum").isHidden()); QVERIFY(shown.titled(QStringLiteral("Apply outside this range instead")).isHidden()); for (QFrame *frame : shown.sheet->findChildren()) { if (frame->frameShape() == QFrame::VLine || frame->frameShape() == QFrame::HLine) QCOMPARE(frame->foregroundRole(), QPalette::Mid); if (frame->frameShape() == QFrame::VLine) QVERIFY(frame->isHidden()); } for (const char *title : {"Hue", "Saturation", "Lightness"}) { const QString lower = QString::fromLatin1(title).toLower(); // Swift's coloured slider: centred, its help naming the reset. CameraRawSlider &slider = *shown.sheet->findChild(lower + QStringLiteral("Slider")); QVERIFY(slider.minimum() == 0 && slider.maximum() == 1000 && slider.value() == 500 && slider.shown() == 0); QCOMPARE(slider.toolTip(), QString(title) + QStringLiteral(". Double-click to reset.")); QCOMPARE(labelled(slider), QString(title)); QLineEdit &field = *shown.sheet->findChild(lower + QStringLiteral("Field")); QCOMPARE(field.text(), QString("0")); QCOMPARE(field.width(), 48); QCOMPARE(field.alignment(), Qt::AlignRight); QCOMPARE(name(field), QString(title)); QCOMPARE(field.placeholderText(), QString(title)); // Title 76 wide; slider 10 on; unit 2 past. QLabel *heading = field.parentWidget()->findChildren().first(); QLabel *unit = field.parentWidget()->findChildren().last(); QCOMPARE(heading->width(), 76); QCOMPARE(slider.x() - heading->geometry().right() - 1, 10); QCOMPARE(unit->x() - field.geometry().right() - 1, 2); QCOMPARE(unit->text(), QString(lower == QStringLiteral("hue") ? "°" : "")); } QVERIFY(!shown.titled(QStringLiteral("Colorize")).isChecked()); QVERIFY(shown.titled(QStringLiteral("Preview")).isChecked()); // Toggles 18 apart, the buttons at their own widths. QCheckBox &colorize = shown.titled(QStringLiteral("Colorize")), &preview = shown.titled(QStringLiteral("Preview")); QCOMPARE(preview.x() - colorize.geometry().right() - 1, 18); QPushButton &ok = shown.titled(QStringLiteral("OK")), &reset = shown.titled(QStringLiteral("Reset")); QVERIFY(ok.isDefault()); for (QPushButton *button : {&ok, &reset, &shown.titled(QStringLiteral("Cancel"))}) { QCOMPARE(button->width(), button->sizeHint().width()); QVERIFY(button == &ok || !button->autoDefault()); } QLabel &limited = shown.child("hueLimited"); QCOMPARE(limited.text(), QString("Limited to the selection")); QCOMPARE(limited.font().pixelSize(), 12); QCOMPARE(limited.foregroundRole(), QPalette::PlaceholderText); QVERIFY(limited.isHidden()); // Swift's help and names for each tool. QToolButton &targeting = shown.child("hueTargeting"); QCOMPARE(targeting.toolTip(), QString("Targeted adjustment: drag on the image to change that color's saturation, or its hue with Ctrl held")); QCOMPARE(name(targeting), QString("Targeted adjustment")); QCOMPARE(targeting.size(), QSize(24, 20)); const std::pair samplers[] = {{"hueSample", HueSampleMode::replace}, {"hueAdd", HueSampleMode::add}, {"hueRemove", HueSampleMode::remove}}; for (const auto &[object, mode] : samplers) { QToolButton &sampler = shown.child(object); QCOMPARE(sampler.toolTip(), help(mode)); QCOMPARE(name(sampler), rawValue(mode) + QStringLiteral(" color")); QCOMPARE(sampler.size(), QSize(24, 20)); } // Over a selection it is limited; the sheet says so. shown.session.cancelHueSaturation(); QPainterPath left; left.addRect(0, 0, 2, 1); shown.session.applySelection(left, SelectionMode::replace, QStringLiteral("Select")); shown.session.beginHueSaturation(); QVERIFY(!limited.isHidden()); } void HueSaturationSheetTests::aRangeShowsItsSpectrumAndSamplers() { Sheet shown; QVERIFY(shown.show()); QComboBox &range = shown.child("hueRange"); range.setFocus(); QTest::keyClick(&range, Qt::Key_Down); QCOMPARE(shown.settings().range, ColorRange::reds); for (const char *sampler : {"hueSample", "hueAdd", "hueRemove"}) QVERIFY2(shown.child(sampler).isVisible(), sampler); QToolButton &sample = shown.child("hueSample"), &add = shown.child("hueAdd"); QCOMPARE(add.x() - sample.geometry().right() - 1, 6); QFrame *divider = nullptr; for (QFrame *frame : shown.sheet->findChildren()) { if (frame->frameShape() == QFrame::VLine) divider = frame; } QVERIFY(divider && divider->isVisible() && divider->height() == 16); QVERIFY(shown.child("hueSpectrum").isVisible()); QVERIFY(shown.titled(QStringLiteral("Apply outside this range instead")).isVisible()); QLabel &readout = shown.child("spectrumReadout"); QCOMPARE(readout.text(), QString("315° 345° 15° 45°")); QCOMPARE(readout.font().pixelSize(), 10); QCOMPARE(readout.foregroundRole(), QPalette::PlaceholderText); // Colorize: hue and saturation set outright, no range. shown.titled(QStringLiteral("Colorize")).click(); QVERIFY(shown.settings() == HueSaturationSettings::colorizeStart()); QVERIFY(!range.isEnabled()); QVERIFY(!shown.child("hueTargeting").isVisible() && !shown.child("hueSpectrum").isVisible()); CameraRawSlider &hue = *shown.sheet->findChild(QStringLiteral("hueSlider")); CameraRawSlider &saturation = *shown.sheet->findChild(QStringLiteral("saturationSlider")); // Both ranges now start at zero. QVERIFY(hue.value() == 0 && hue.shown() == 0); QVERIFY(saturation.value() == 250 && saturation.shown() == 25); QCOMPARE(shown.child("saturationField").text(), QString("25")); // Off again: everything back to the start. shown.titled(QStringLiteral("Colorize")).click(); QVERIFY(shown.settings() == HueSaturationSettings()); QVERIFY(range.isEnabled() && shown.child("hueTargeting").isVisible()); // A range under Colorize still shows no spectrum. shown.set(HueSaturationSettings(0, 25, 0, true, ColorRange::reds)); QVERIFY(!shown.child("hueSpectrum").isVisible() && !sample.isVisible()); } void HueSaturationSheetTests::slidersAndFieldsWriteTheirValue() { Sheet shown; QVERIFY(shown.show()); CameraRawSlider &hue = *shown.sheet->findChild(QStringLiteral("hueSlider")); CameraRawSlider &saturationSlider = *shown.sheet->findChild(QStringLiteral("saturationSlider")); // 30.6 along the track: Swift sets the whole number. hue.setValue(585); QCOMPARE(shown.settings().hue(), 31.0); QLineEdit &hueField = shown.child("hueField"); QCOMPARE(hueField.text(), QString("31")); // Visited without typing, the shown whole number stays unread. hueField.setFocus(); QTRY_VERIFY(hueField.hasFocus()); QLineEdit &saturation = shown.child("saturationField"); saturation.setFocus(); QTRY_VERIFY(saturation.hasFocus()); QCOMPARE(shown.settings().hue(), 31.0); // Typed and left, a number applies unclamped. type(saturation, QStringLiteral("250")); hueField.setFocus(); QCOMPARE(shown.settings().saturation(), 250.0); QCOMPARE(saturationSlider.value(), 1000); QCOMPARE(saturation.text(), QString("250")); // Applied, the field follows the session again. saturationSlider.setValue(400); QCOMPARE(saturation.text(), QString("-20")); // What is no finite number reverts. for (const char *text : {"abc", "inf", "nan"}) { type(hueField, QString::fromLatin1(text)); saturation.setFocus(); QCOMPARE(shown.settings().hue(), 31.0); QCOMPARE(hueField.text(), QString("31")); } // Past any int, the slider rests at its end. type(saturation, QStringLiteral("1e300")); hueField.setFocus(); QCOMPARE(saturationSlider.value(), 1000); // Sliders show Colorize's range without writing. HueSaturationSettings colour = HueSaturationSettings::colorizeStart(); colour.setHue(-40); shown.set(colour); QCOMPARE(hue.value(), 0); QCOMPARE(shown.settings().hue(), -40.0); QCOMPARE(hueField.text(), QString("-40")); // Typing waits through other values; its own slider replaces it. type(hueField, QStringLiteral("4")); saturationSlider.setValue(100); QVERIFY(hueField.text() == "4" && hueField.isModified()); hue.setValue(250); QVERIFY(hueField.text() == "90" && !hueField.isModified() && hueField.hasFocus()); saturation.setFocus(); QCOMPARE(shown.settings().hue(), 90.0); } void HueSaturationSheetTests::fieldsClampOnReturnAndPrintWhole() { Sheet shown; QVERIFY(shown.show()); // Return clamps into the slider's range. QLineEdit &lightness = shown.child("lightnessField"); type(lightness, QStringLiteral("-150")); QTest::keyClick(&lightness, Qt::Key_Return); QCOMPARE(shown.settings().lightness(), -100.0); QCOMPARE(lightness.text(), QString("-100")); // Under Colorize, Colorize's range. shown.set(HueSaturationSettings::colorizeStart()); QLineEdit &hue = shown.child("hueField"); type(hue, QStringLiteral("400")); QTest::keyClick(&hue, Qt::Key_Return); QCOMPARE(shown.settings().hue(), 360.0); // The user's decimal mark reads; digits print ungrouped. lightness.setLocale(QLocale(QLocale::German)); type(lightness, QStringLiteral("12,4")); hue.setFocus(); QCOMPARE(shown.settings().lightness(), 12.4); QCOMPARE(lightness.text(), QString("12")); type(lightness, QStringLiteral("12345")); hue.setFocus(); QCOMPARE(shown.settings().lightness(), 12345.0); QCOMPARE(lightness.text(), QString("12345")); } void HueSaturationSheetTests::samplersAndTargetingToggle() { Sheet shown; shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::reds)); QVERIFY(shown.show()); QToolButton &sample = shown.child("hueSample"), &add = shown.child("hueAdd"), &remove = shown.child("hueRemove"), &targeting = shown.child("hueTargeting"); shown.session.setHueTargeting(true); sample.click(); QVERIFY(shown.session.hueSampleMode() == HueSampleMode::replace && !shown.session.hueTargeting()); QVERIFY(sample.isChecked() && !add.isChecked() && !targeting.isChecked()); // A second click puts the eyedropper away. sample.click(); QVERIFY(!shown.session.hueSampleMode() && !sample.isChecked()); add.click(); QVERIFY(shown.session.hueSampleMode() == HueSampleMode::add && add.isChecked()); targeting.click(); QVERIFY(!shown.session.hueSampleMode() && shown.session.hueTargeting()); QVERIFY(targeting.isChecked() && !add.isChecked()); remove.click(); QVERIFY(shown.session.hueSampleMode() == HueSampleMode::remove && !shown.session.hueTargeting()); targeting.click(); targeting.click(); QVERIFY(!shown.session.hueSampleMode() && !shown.session.hueTargeting() && !targeting.isChecked()); } void HueSaturationSheetTests::theControlsFollowTheSession() { Sheet shown; QComboBox &range = shown.child("hueRange"); HueSaturationSettings yellows(0, 0, 0, false, ColorRange::yellows); yellows.invertRange = true; shown.session.updateHueSaturation(yellows, false); QCOMPARE(range.currentText(), QString("Yellows")); QVERIFY(shown.titled(QStringLiteral("Apply outside this range instead")).isChecked()); QVERIFY(!shown.titled(QStringLiteral("Preview")).isChecked()); shown.session.updateHueSaturation(HueSaturationSettings::colorizeStart(), true); QVERIFY(shown.titled(QStringLiteral("Colorize")).isChecked()); QVERIFY(shown.titled(QStringLiteral("Preview")).isChecked()); QCOMPARE(range.currentText(), QString("Master")); } void HueSaturationSheetTests::togglesResetAndTheButtons() { Sheet shown; shown.set(HueSaturationSettings(30, 0, 0, false, ColorRange::reds)); QCheckBox &preview = shown.titled(QStringLiteral("Preview")); preview.click(); QVERIFY(!shown.session.hueSaturation().value().preview); // Off, the controls keep Preview off. shown.sheet->findChild(QStringLiteral("lightnessSlider"))->setValue(525); QVERIFY(!shown.session.hueSaturation().value().preview && shown.settings().lightness() == 5); preview.click(); QVERIFY(shown.session.hueSaturation().value().preview); QCheckBox &invert = shown.titled(QStringLiteral("Apply outside this range instead")); invert.click(); QVERIFY(shown.settings().invertRange); invert.click(); QVERIFY(!shown.settings().invertRange); // Reset: back to the start, or Colorize's start. shown.titled(QStringLiteral("Reset")).click(); QVERIFY(shown.settings() == HueSaturationSettings()); HueSaturationSettings colour = HueSaturationSettings::colorizeStart(); colour.setHue(200); shown.set(colour); shown.titled(QStringLiteral("Reset")).click(); QVERIFY(shown.settings() == HueSaturationSettings::colorizeStart()); // OK commits one step; Cancel leaves the pixels. shown.titled(QStringLiteral("OK")).click(); QTRY_VERIFY(!shown.session.hueSaturation()); QCOMPARE(shown.session.history.undoName(), QString("Hue/Saturation")); const int steps = shown.session.history.undoCount(); shown.session.beginHueSaturation(); shown.set(HueSaturationSettings(90)); shown.titled(QStringLiteral("Cancel")).click(); QVERIFY(!shown.session.hueSaturation()); QCOMPARE(shown.session.history.undoCount(), steps); } void HueSaturationSheetTests::tracksFollowTheRangeAndColorize() { using Kind = CameraRawSliderTrack::Kind; Sheet shown; const auto track = [&](const char *name) { return shown.child(name).track(); }; // Master: circle round red, gray to red, black to white. QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 0); QCOMPARE(track("saturationSlider").kind, Kind::chroma); QVERIFY(track("lightnessSlider").kind == Kind::opposing && track("lightnessSlider").colors() == (std::vector{Qt::black, Qt::white})); // A range centres both on its own hue. shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::cyans)); QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 180); QVERIFY(track("saturationSlider").kind == Kind::saturation && track("saturationSlider").degrees == 180); shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::magentas)); QCOMPARE(track("hueSlider").degrees, 300.0); // Colorize: red to red; saturation towards the chosen hue. shown.set(HueSaturationSettings(200, 25, 0, true)); QVERIFY(track("hueSlider").kind == Kind::spectrum && track("hueSlider").degrees == 180); QVERIFY(track("saturationSlider").kind == Kind::saturation && track("saturationSlider").degrees == 200); // Its knob at 200 of 0 to 360. QCOMPARE(shown.child("hueSlider").value(), 556); } void HueSaturationSheetTests::aDoubleClickResetsToNoneOrColorizesStart() { Sheet shown; QVERIFY(shown.show()); const auto title = [&](const char *text) -> QLabel & { return shown.titled(QString::fromLatin1(text)); }; shown.set(HueSaturationSettings(30, -40, 20, false, ColorRange::reds)); // The title: that value alone back to none. QTest::mouseDClick(&title("Hue"), Qt::LeftButton); QVERIFY(shown.settings().hue() == 0 && shown.settings().saturation() == -40 && shown.settings().lightness() == 20); // The knob. CameraRawSlider &lightness = shown.child("lightnessSlider"); QPoint knob; for (int x = 0; x < lightness.width() && knob.isNull(); ++x) knob = lightness.isOnKnob(QPoint(x, lightness.height() / 2)) ? QPoint(x, lightness.height() / 2) : knob; QTest::mouseDClick(&lightness, Qt::LeftButton, {}, knob); QVERIFY(shown.settings().lightness() == 0 && shown.settings().saturation() == -40); // Under Colorize, back to its start: saturation 25. shown.set(HueSaturationSettings(200, 70, 10, true)); QTest::mouseDClick(&title("Saturation"), Qt::LeftButton); QVERIFY(shown.settings().saturation() == 25 && shown.settings().hue() == 200 && shown.settings().lightness() == 10); QCOMPARE(shown.child("saturationField").text(), QString("25")); } void HueSaturationSheetTests::aResetReplacesTheFieldsTyping() { Sheet shown; QVERIFY(shown.show()); shown.set(HueSaturationSettings(200, 70, 10, true)); QLineEdit &hueField = shown.child("hueField"); hueField.setFocus(); QTRY_VERIFY(hueField.hasFocus()); hueField.selectAll(); QTest::keyClicks(&hueField, QStringLiteral("77")); QTest::mouseDClick(&shown.titled(QStringLiteral("Hue")), Qt::LeftButton); QCOMPARE(shown.settings().hue(), 0.0); QCOMPARE(hueField.text(), QString("0")); QVERIFY(!hueField.isModified()); } void HueSaturationSheetTests::arrowKeysWalkEverySlider() { // A hundred steps: whole values, the knob keeping its travel. const auto walk = [](Sheet &shown, const char *name, Qt::Key key) { CameraRawSlider &slider = shown.child(name); slider.setFocus(); for (int step = 0; step < 100; ++step) QTest::keyClick(&slider, key); }; const std::tuple cases[] = { {false, "hueSlider", Qt::Key_Right, 53}, {false, "hueSlider", Qt::Key_Left, -53}, {false, "saturationSlider", Qt::Key_Right, 33}, {false, "lightnessSlider", Qt::Key_Left, -33}, {true, "hueSlider", Qt::Key_Right, 53}, {true, "saturationSlider", Qt::Key_Right, 45}, {true, "saturationSlider", Qt::Key_Left, 9}, {true, "lightnessSlider", Qt::Key_Right, 33}}; for (const auto &[colorize, name, key, expected] : cases) { Sheet shown; QVERIFY(shown.show()); shown.set(colorize ? HueSaturationSettings::colorizeStart() : HueSaturationSettings()); walk(shown, name, key); const HueSaturationSettings &now = shown.settings(); const double value = QByteArray(name) == "hueSlider" ? now.hue() : QByteArray(name) == "saturationSlider" ? now.saturation() : now.lightness(); QVERIFY2(value == expected, qPrintable(QStringLiteral("%1 %2: %3").arg(QString::fromLatin1(name)).arg(colorize).arg(value))); } // An unchanged step leaves the field's typing. Sheet shown; QVERIFY(shown.show()); QLineEdit &field = shown.child("hueField"); field.setFocus(); QTRY_VERIFY(field.hasFocus()); field.selectAll(); QTest::keyClicks(&field, QStringLiteral("4")); QTest::keyClick(&shown.child("hueSlider"), Qt::Key_Right); QVERIFY(field.isModified() && field.text() == "4" && shown.settings().hue() == 0); } void HueSaturationSheetTests::aResetKeepsPreviewOffInBothModes() { for (const bool colorize : {false, true}) { Sheet shown; QVERIFY(shown.show()); shown.session.updateHueSaturation(HueSaturationSettings(90, 60, 30, colorize), false); QTest::mouseDClick(&shown.titled(QStringLiteral("Saturation")), Qt::LeftButton); QCOMPARE(shown.settings().saturation(), colorize ? 25.0 : 0.0); QVERIFY(shown.settings().hue() == 90 && shown.settings().lightness() == 30); QCOMPARE(shown.child("saturationField").text(), colorize ? QString("25") : QString("0")); QVERIFY(!shown.session.hueSaturation().value().preview); CameraRawSlider &lightness = shown.child("lightnessSlider"); QPoint knob; for (int x = 0; x < lightness.width() && knob.isNull(); ++x) knob = lightness.isOnKnob(QPoint(x, lightness.height() / 2)) ? QPoint(x, lightness.height() / 2) : knob; QTest::mouseDClick(&lightness, Qt::LeftButton, {}, knob); QVERIFY(shown.settings().lightness() == 0 && shown.settings().hue() == 90); QVERIFY(!shown.session.hueSaturation().value().preview); } } void HueSaturationSheetTests::aRangeUnderColorizeKeepsItsTracks() { using Kind = CameraRawSliderTrack::Kind; Sheet shown; shown.set(HueSaturationSettings(200, 25, 0, true, ColorRange::greens)); CameraRawSlider &hue = shown.child("hueSlider"); CameraRawSlider &saturation = shown.child("saturationSlider"); // Red to red, towards the chosen hue, Colorize's bounds. QVERIFY(hue.track().kind == Kind::spectrum && hue.track().degrees == 180); QVERIFY(saturation.track().kind == Kind::saturation && saturation.track().degrees == 200); QVERIFY(hue.value() == 556 && saturation.value() == 250); } void HueSaturationSheetTests::aTitleResetsOnALeftDoubleClickThenScrubs() { Sheet shown; QVERIFY(shown.show()); shown.set(HueSaturationSettings(30, 0, 0, false)); QLabel &title = shown.titled(QStringLiteral("Hue")); const auto send = [&title](QEvent::Type type, double x, Qt::MouseButton button, Qt::MouseButtons buttons) { QMouseEvent event(type, QPointF(x, 2), title.mapToGlobal(QPointF(x, 2)), button, buttons, Qt::NoModifier); QApplication::sendEvent(&title, &event); }; // Other buttons and a resting sheet reset nothing. for (const Qt::MouseButton button : {Qt::RightButton, Qt::MiddleButton}) send(QEvent::MouseButtonDblClick, 2, button, button); shown.sheet->setEnabled(false); send(QEvent::MouseButtonDblClick, 2, Qt::LeftButton, Qt::LeftButton); shown.sheet->setEnabled(true); QCOMPARE(shown.settings().hue(), 30.0); // A real double click resets; its second press scrubs. send(QEvent::MouseButtonPress, 2, Qt::LeftButton, Qt::LeftButton); send(QEvent::MouseButtonRelease, 2, Qt::LeftButton, Qt::NoButton); send(QEvent::MouseButtonDblClick, 2, Qt::LeftButton, Qt::LeftButton); QCOMPARE(shown.settings().hue(), 0.0); send(QEvent::MouseMove, 14, Qt::NoButton, Qt::LeftButton); send(QEvent::MouseButtonRelease, 14, Qt::LeftButton, Qt::NoButton); QCOMPARE(shown.settings().hue(), 12.0); } QTEST_MAIN(HueSaturationSheetTests) #include "HueSaturationSheetTests.moc"