Filter › Dither, as Compositor 1.3.3 (12.15)

This commit is contained in:
雷电芽衣 committed 2026-09-30 07:55:57 -04:00
1 parent aa5a44e517
commit bc9d43f1e8
25 files changed
+1973 -17

No files matched your search

+440
View File
@@ -0,0 +1,440 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "UI/ColorPaletteControls.h"
#include "UI/FilterSheet.h"
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QProgressBar>
#include <QSlider>
#include <QtTest>
// Swift 1.3.3's Dither: the kind, its colours, its sheet.
namespace {
struct Shown {
EditorSession session;
QImage image;
std::unique_ptr<FilterSheet> sheet;
explicit Shown(int width = 40, int height = 30)
{
session.createDocument(width, height);
image = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x)
image.setPixelColor(x, y, QColor(x * 255 / std::max(1, width - 1), 120, 200));
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
session.beginFilter(FilterKind::dither);
sheet = std::make_unique<FilterSheet>(session);
sheet->show();
}
const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; }
void set(const std::function<void(DitherSettings &)> &change)
{
FilterSettings settings = session.filterEdit().value().settings;
change(settings.dither);
session.updateFilter(settings, true);
}
template <typename Widget> Widget &child(const char *name) const
{
auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(name);
return *found;
}
// The rows shown, by their titles, sorted.
QStringList shown() const
{
QStringList titles;
for (const QLabel *label : sheet->findChildren<QLabel *>()) {
if (label->buddy() && !label->parentWidget()->isHidden() && label->isVisibleTo(sheet.get()))
titles << label->text();
}
for (const QCheckBox *box : sheet->findChildren<QCheckBox *>()) {
if (box->isVisibleTo(sheet.get()) && box->text() != QLatin1String("Preview"))
titles << box->text();
}
if (child<QWidget>("ditherDark").isVisibleTo(sheet.get()))
titles << QStringLiteral("Swatches");
titles.sort();
return titles;
}
};
QStringList sorted(QStringList titles)
{
titles.sort();
return titles;
}
void pick(EditorSession &session, const PaletteColor &color)
{
PickerHSB hsb = session.colorPicker().value().hsb;
hsb.setRGB(color);
session.setColorPickerHSB(hsb);
}
}
class DitherSheetTests : public QObject {
Q_OBJECT
private slots:
void theKindSitsUnderBloomAndPreviewsWhole();
void itsCommitIsTheSettingsApplied();
void theTwoColoursHaveTheirPicker();
void theControlsFollowTheStyle();
void theMenusAndFieldsWriteTheSettings();
void theSheetPreviewsThePickersColour();
void eachNumberWritesItsOwnSetting();
void leftTheCharactersShowWhatWasKept();
void theSlidersReachTheirEnds();
void aCommitSaysItIsApplying();
void thePickerRefusesWhenItCannot();
void anAsciiCommitDrawsOnTheWorker();
void rowsKeepSwiftsOrderHelpAndScale();
};
void DitherSheetTests::theKindSitsUnderBloomAndPreviewsWhole()
{
QCOMPARE(rawValue(FilterKind::dither), QString("Dither"));
const auto at = std::find(allFilterKinds.begin(), allFilterKinds.end(), FilterKind::dither);
QVERIFY(at != allFilterKinds.end() && *(at - 1) == FilterKind::bloomGlow && *(at + 1) == FilterKind::tonalContrast);
QVERIFY(!isAutomatic(FilterKind::dither) && !isImageAdjustment(FilterKind::dither));
// Past the preview limit, Dither still previews at full size.
EditorSession session;
session.createDocument(3000, 10);
const QImage wide = BrushRaster::context(3000, 10, false);
session.insert(ImportedImage(wide, wide, QStringLiteral("Wide")));
session.beginFilter(FilterKind::dither);
QCOMPARE(session.filterEdit().value().previewScale, 1.0);
QCOMPARE(session.filterEdit().value().previewSource.size(), QSize(3000, 10));
session.cancelFilter();
session.beginFilter(FilterKind::gaussianBlur);
QVERIFY(session.filterEdit().value().previewScale < 1);
}
void DitherSheetTests::itsCommitIsTheSettingsApplied()
{
Shown shown;
shown.set([](DitherSettings &dither) {
dither.style = DitherStyle::bayer4;
dither.pixelSize = 3;
});
const DitherSettings settings = shown.dither();
bool done = false;
shown.session.commitFilter([&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(shown.session.history.undoName(), QString("Dither"));
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image));
}
void DitherSheetTests::theTwoColoursHaveTheirPicker()
{
Shown shown;
shown.session.openDitherColorPicker(false);
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Dark Color)"));
QVERIFY(shown.session.colorPicker().value().original == PaletteColor::black());
pick(shown.session, PaletteColor{0, 0, 1});
shown.session.previewDitherColor();
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 1));
// Cancel puts it back; OK on the light keeps it.
shown.session.closeColorPicker(false);
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
shown.session.openDitherColorPicker(true);
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Light Color)"));
pick(shown.session, PaletteColor{1, 1, 0});
shown.session.closeColorPicker(true);
QVERIFY(shown.dither().light == AdjustmentColor(1, 1, 0) && shown.dither().dark == AdjustmentColor(0, 0, 0));
// The swatches open it; the panel's close takes it along.
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton);
QVERIFY(shown.session.colorPicker().has_value() && !shown.session.colorPicker().value().target.light);
pick(shown.session, PaletteColor{1, 0, 0});
shown.session.previewDitherColor();
shown.session.cancelFilter();
QVERIFY(!shown.session.colorPicker() && !shown.session.filterEdit());
// Not over another filter.
shown.session.beginFilter(FilterKind::gaussianBlur);
shown.session.openDitherColorPicker(true);
QVERIFY(!shown.session.colorPicker());
}
void DitherSheetTests::theControlsFollowTheStyle()
{
Shown shown;
const QStringList common{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Pixel Shape"};
QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones", "Diffusion"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::bayer8; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Angle", "Light on Dark"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Characters", "Light on Dark"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::patterns; });
QCOMPARE(shown.shown(), sorted(common + QStringList{"Light on Dark"}));
// Pixel Shape needs chunky pixels; the swatches, Two Colors.
shown.set([](DitherSettings &dither) {
dither.pixelSize = 1;
dither.colors = DitherColors::twoColors;
});
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"}));
// Style's menu: ten looks in four groups.
QComboBox &style = shown.child<QComboBox>("ditherStyle");
QStringList items;
for (int index = 0; index < style.count(); ++index)
items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index));
QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots",
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"}));
QCOMPARE(style.currentText(), QString("Mac Patterns"));
// Its quick preview never shows the busy line.
QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
}
void DitherSheetTests::theMenusAndFieldsWriteTheSettings()
{
Shown shown;
QComboBox &style = shown.child<QComboBox>("ditherStyle");
const int ascii = style.findData(int(DitherStyle::ascii));
style.setCurrentIndex(ascii);
emit style.activated(ascii);
QVERIFY(shown.dither().style == DitherStyle::ascii);
QComboBox &colors = shown.child<QComboBox>("ditherColors");
colors.setCurrentIndex(2);
emit colors.activated(2);
QVERIFY(shown.dither().colors == DitherColors::original);
QComboBox &shape = shown.child<QComboBox>("ditherPixelShape");
shape.setCurrentIndex(1);
emit shape.activated(1);
QVERIFY(shown.dither().pixelShape == DitherPixelShape::dot);
// Characters as typed, stored without line breaks.
QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters");
QCOMPARE(characters.text(), DitherSettings::defaultCharacters());
characters.setFocus();
characters.selectAll();
QTest::keyClicks(&characters, QStringLiteral("#o."));
QCOMPARE(shown.dither().characters, QString("#o."));
// Unfocused, it shows the settings as they stand.
characters.clearFocus();
shown.set([](DitherSettings &dither) { dither.characters = QStringLiteral("xy"); });
QCOMPARE(characters.text(), QString("xy"));
}
void DitherSheetTests::theSheetPreviewsThePickersColour()
{
Shown shown;
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
for (const bool light : {false, true}) {
QTest::mouseClick(&shown.child<SwatchButton>(light ? "ditherLight" : "ditherDark"), Qt::LeftButton);
QVERIFY(shown.session.colorPicker().has_value() && shown.session.colorPicker().value().target.light == light);
// The sheet previews the working colour itself.
pick(shown.session, PaletteColor{0, 1, 0});
QVERIFY((light ? shown.dither().light : shown.dither().dark) == AdjustmentColor(0, 1, 0));
shown.session.closeColorPicker(false);
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0) && shown.dither().light == AdjustmentColor(1, 1, 1));
}
// The filter's OK takes an open picker's colour along.
QTest::mouseClick(&shown.child<SwatchButton>("ditherLight"), Qt::LeftButton);
pick(shown.session, PaletteColor{1, 0, 0});
DitherSettings expected = shown.dither();
expected.light = AdjustmentColor(1, 0, 0);
bool done = false;
shown.session.commitFilter([&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!shown.session.colorPicker());
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), expected.apply(shown.image));
}
void DitherSheetTests::eachNumberWritesItsOwnSetting()
{
Shown shown;
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
struct Row {
const char *field;
double DitherSettings::*setting;
const char *typed;
double expected;
};
const Row rows[] = {{"pixelSizeField", &DitherSettings::pixelSize, "5", 5}, {"cellSizeField", &DitherSettings::cellSize, "12", 12},
{"angleField", &DitherSettings::angle, "-30", -30}, {"densityField", &DitherSettings::density, "25", 25},
{"contrastField", &DitherSettings::contrast, "-40", -40}};
for (const Row &row : rows) {
const DitherSettings before = shown.dither();
QLineEdit &field = shown.child<QLineEdit>(row.field);
field.setFocus();
field.selectAll();
QTest::keyClicks(&field, QString::fromLatin1(row.typed));
QTest::keyClick(&field, Qt::Key_Return);
DitherSettings expected = before;
expected.*row.setting = row.expected;
QVERIFY2(shown.dither() == expected, row.field);
}
// Tones and Diffusion under a diffusion style.
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::floydSteinberg; });
for (const auto &[name, setting, value] : {std::tuple("tonesField", &DitherSettings::levels, 4.0), std::tuple("diffusionField", &DitherSettings::diffusion, 60.0)}) {
QLineEdit &field = shown.child<QLineEdit>(name);
field.setFocus();
field.selectAll();
QTest::keyClicks(&field, QString::number(value));
QTest::keyClick(&field, Qt::Key_Return);
QCOMPARE(shown.dither().*setting, value);
}
// Light on Dark, under marks.
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::dots; });
QCheckBox *glow = nullptr;
for (QCheckBox *box : shown.sheet->findChildren<QCheckBox *>())
glow = box->text() == QLatin1String("Light on Dark") ? box : glow;
QVERIFY(glow && glow->isChecked());
glow->click();
QVERIFY(!shown.dither().lightOnDark);
}
void DitherSheetTests::leftTheCharactersShowWhatWasKept()
{
Shown shown;
QVERIFY(QTest::qWaitForWindowActive(shown.sheet.get()));
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters");
characters.setFocus();
QTRY_VERIFY(characters.hasFocus());
characters.selectAll();
QTest::keyClicks(&characters, QString(70, u'x'));
// Kept to 64; the typing shows until left.
QCOMPARE(shown.dither().characters, QString(64, u'x'));
QCOMPARE(characters.text(), QString(70, u'x'));
shown.child<QLineEdit>("densityField").setFocus();
QTRY_VERIFY(!characters.hasFocus());
QCOMPARE(characters.text(), QString(64, u'x'));
QVERIFY(!shown.session.filterEdit().value().preview);
}
void DitherSheetTests::theSlidersReachTheirEnds()
{
Shown shown;
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
const std::tuple<const char *, double DitherSettings::*, double, double> rows[] = {
{"pixelSizeSlider", &DitherSettings::pixelSize, 1, 32}, {"cellSizeSlider", &DitherSettings::cellSize, 4, 64},
{"angleSlider", &DitherSettings::angle, -90, 90}, {"densitySlider", &DitherSettings::density, -100, 100},
{"contrastSlider", &DitherSettings::contrast, -100, 100}};
for (const auto &[name, setting, low, high] : rows) {
QSlider &slider = shown.child<QSlider>(name);
slider.setValue(slider.maximum());
QCOMPARE(shown.dither().*setting, high);
slider.setValue(slider.minimum());
QCOMPARE(shown.dither().*setting, low);
}
QCOMPARE(shown.child<QSlider>("pixelSizeSlider").parentWidget()->toolTip(),
QString("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::atkinson; });
for (const auto &[name, setting, low, high] : {std::tuple("tonesSlider", &DitherSettings::levels, 2.0, 8.0),
std::tuple("diffusionSlider", &DitherSettings::diffusion, 0.0, 100.0)}) {
QSlider &slider = shown.child<QSlider>(name);
slider.setValue(slider.maximum());
QCOMPARE(shown.dither().*setting, high);
slider.setValue(slider.minimum());
QCOMPARE(shown.dither().*setting, low);
}
}
void DitherSheetTests::aCommitSaysItIsApplying()
{
// Quick previews never show the line; a commit does.
Shown shown;
bool done = false;
shown.session.commitFilter([&done] { done = true; });
QVERIFY(shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
QCOMPARE(shown.child<QLabel>("filterActivity").text(), QString("Applying…"));
QTRY_VERIFY(done);
}
void DitherSheetTests::thePickerRefusesWhenItCannot()
{
Shown shown;
// Busy, the palette rests.
shown.session.setIsProjectBusy(true);
shown.session.openDitherColorPicker(false);
QVERIFY(!shown.session.colorPicker());
shown.session.setIsProjectBusy(false);
// Another picker open stays so.
shown.session.openDitherColorPicker(true);
shown.session.openDitherColorPicker(false);
QVERIFY(shown.session.colorPicker().value().target.light);
shown.session.closeColorPicker(false);
// A style change keeps an open picker on its colour.
shown.session.openDitherColorPicker(false);
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
pick(shown.session, PaletteColor{1, 0, 1});
shown.session.previewDitherColor();
QVERIFY(shown.dither().dark == AdjustmentColor(1, 0, 1) && shown.dither().style == DitherStyle::ascii);
shown.session.closeColorPicker(false);
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
}
void DitherSheetTests::anAsciiCommitDrawsOnTheWorker()
{
// Glyphs are drawn off the UI thread, in the commit.
Shown shown(96, 48);
shown.set([](DitherSettings &dither) {
dither.style = DitherStyle::ascii;
dither.cellSize = 12;
dither.pixelSize = 1;
});
const DitherSettings settings = shown.dither();
bool done = false;
shown.session.commitFilter([&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image));
}
void DitherSheetTests::rowsKeepSwiftsOrderHelpAndScale()
{
Shown shown;
// The shown rows, in the column's order, with their help.
const auto rows = [&shown] {
QStringList titles;
QLayout *column = shown.sheet->layout();
for (int index = 0; index < column->count(); ++index) {
QWidget *row = column->itemAt(index)->widget();
if (!row || row->isHidden())
continue;
if (auto *box = qobject_cast<QCheckBox *>(row); box && box->text() != QLatin1String("Preview"))
titles << box->text() + u'|' + box->toolTip();
for (QLabel *label : row->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) {
if (label->buddy())
titles << label->text() + u'|' + row->toolTip();
}
}
return titles;
};
QCOMPARE(rows(), (QStringList{"Style|", "Pixel Size|Make each dithered pixel this many pixels across, for a chunky old-screen look",
"Tones|Tones per channel: 2 is pure black and white",
"Diffusion|How much of each pixel's error spreads to its neighbors. Less gives flatter areas",
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|",
"Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"}));
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
QCOMPARE(rows().mid(2, 2), (QStringList{"Cell Size|", "Characters|The characters to draw with, in any order: each spot gets the one whose ink best matches its tone"}));
QCOMPARE(rows().last(), QString("Light on Dark|Draw the marks for the light tones on the dark color, like a glowing screen"));
// Linear: half the Pixel Size travel is 16.5, so 17.
QSlider &pixels = shown.child<QSlider>("pixelSizeSlider");
pixels.setValue(pixels.maximum() / 2);
QCOMPARE(shown.dither().pixelSize, 17.0);
// Across Colors a picker keeps on; without an edit, none.
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton);
FilterSettings original = shown.session.filterEdit().value().settings;
original.dither.colors = DitherColors::original;
shown.session.updateFilter(original, false);
pick(shown.session, PaletteColor{0, 1, 1});
QVERIFY(shown.dither().dark == AdjustmentColor(0, 1, 1) && shown.dither().colors == DitherColors::original);
QVERIFY(!shown.session.filterEdit().value().preview);
shown.session.closeColorPicker(false);
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
shown.session.cancelFilter();
shown.session.openDitherColorPicker(false);
QVERIFY(!shown.session.colorPicker());
}
QTEST_MAIN(DitherSheetTests)
#include "DitherSheetTests.moc"