Outer Glow, brush Smoothing and the rest of Compositor 1.2.0

This commit is contained in:
雷电芽衣 committed 2026-09-27 07:00:12 -04:00
1 parent 2cd0d1b86d
commit 374ba8ec73
32 files changed
+624 -47

No files matched your search

+32
View File
@@ -38,6 +38,7 @@ private slots:
void eachBrushToolTitlesTheBarAndShowsItsPicker();
void cloneStampShowsAlignedSampleAndTheSourceNote();
void theSmearShowsItsThreeModes();
void smoothingShowsWithTheBrushAlone();
};
void BrushControlsTests::theTitleAndModesFollowTheSession()
@@ -274,5 +275,36 @@ void BrushControlsTests::theSmearShowsItsThreeModes()
QVERIFY(!modes[1]->isVisible());
}
void BrushControlsTests::smoothingShowsWithTheBrushAlone()
{
EditorSession session;
session.createDocument(8, 8, true);
session.selectTool(NavigationTool::brush);
BrushControls bar(session);
bar.show();
auto &field = find<QLineEdit>(bar, "brushSmoothing");
auto &slider = find<QSlider>(bar, "brushSmoothingSlider");
QVERIFY(field.isVisible() && slider.isVisible());
QCOMPARE(field.text(), QString("0"));
QCOMPARE(field.toolTip(), QString("The brush trails the pointer on a string this long, so a shaky hand still draws a smooth line"));
QVERIFY(slider.minimum() == 0 && slider.maximum() == 1000 && slider.width() == 100 && field.width() == 42);
slider.setValue(255);
QCOMPARE(session.brushSettings().smoothing, 25.5);
QCOMPARE(field.text(), QString("26"));
type(field, "140");
QCOMPARE(session.brushSettings().smoothing, 100.0);
QCOMPARE(slider.value(), 1000);
QTest::keyClick(&field, Qt::Key_Down, Qt::ShiftModifier);
QCOMPARE(session.brushSettings().smoothing, 90.0);
// The other brush tools have their own feel: no Smoothing.
for (const NavigationTool tool : {NavigationTool::spotHealing, NavigationTool::cloneStamp, NavigationTool::blur}) {
session.selectTool(tool);
QVERIFY(!field.isVisible() && !slider.isVisible());
}
session.selectTool(NavigationTool::brush);
session.setBrushMode(BrushToolMode::erase);
QVERIFY(field.isVisible());
}
QTEST_MAIN(BrushControlsTests)
#include "BrushControlsTests.moc"
+119
View File
@@ -0,0 +1,119 @@
#include "Document/EditorSession.h"
#include <QtTest>
// Swift's Smoothing: the brush trails the pointer on a string.
namespace {
std::unique_ptr<EditorSession> painting(double smoothing, NavigationTool tool = NavigationTool::brush)
{
auto session = std::make_unique<EditorSession>();
session->createDocument(200, 200, true);
session->selectTool(tool);
BrushSettings brush = session->brushSettings();
brush.diameter = 4;
brush.hardness = 1;
brush.smoothing = smoothing;
session->setBrushSettings(brush);
return session;
}
QPointF painted(const EditorSession &session)
{
return session.shiftLineStart().value();
}
// The committed pixels at a document point, clear outside them.
int ink(const EditorSession &session, int x, int y)
{
const ImageLayer &layer = session.document().value().layers.back();
const QImage image = layer.asset.value().image();
const QPoint at = QPoint(x, y) - layer.origin().toPoint();
if (layer.size() != QSizeF(image.size()))
throw std::runtime_error("the layer is scaled");
return image.rect().contains(at) ? qAlpha(image.pixel(at)) : 0;
}
}
class BrushSmoothingTests : public QObject {
Q_OBJECT
private slots:
void theBrushWaitsUntilTheStringIsTaut();
void theStrokeEndsWhereTheHandDid();
void theStringIsMeasuredOnScreen();
void onlyTheBrushSmoothsAndACancelLetsGo();
};
void BrushSmoothingTests::theBrushWaitsUntilTheStringIsTaut()
{
const auto session = painting(10);
session->beginBrush(QPointF(50, 50));
session->continueBrush(QPointF(55, 50));
QCOMPARE(painted(*session), QPointF(50, 50));
// Taut at exactly its length, the string still holds.
session->continueBrush(QPointF(60, 50));
QCOMPARE(painted(*session), QPointF(50, 50));
session->continueBrush(QPointF(70, 50));
QCOMPARE(painted(*session), QPointF(60, 50));
// Pulled at an angle, the brush moves along the string.
session->continueBrush(QPointF(70, 60));
const double reach = 10 * std::sqrt(0.5);
QVERIFY(std::abs(painted(*session).x() - (70 - reach)) < 1e-9 && std::abs(painted(*session).y() - (60 - reach)) < 1e-9);
// Without Smoothing the brush is the pointer.
const auto exact = painting(0);
exact->beginBrush(QPointF(50, 50));
exact->continueBrush(QPointF(51, 50));
QCOMPARE(painted(*exact), QPointF(51, 50));
}
void BrushSmoothingTests::theStrokeEndsWhereTheHandDid()
{
const auto session = painting(10);
session->beginBrush(QPointF(50, 50));
session->continueBrush(QPointF(80, 50));
QVERIFY(session->finishBrushImmediately());
QVERIFY(ink(*session, 79, 50) > 0 && ink(*session, 70, 50) > 0);
QCOMPARE(ink(*session, 86, 50), 0);
// A string never pulled taut still ends at the pointer.
const auto still = painting(10);
still->beginBrush(QPointF(50, 50));
still->continueBrush(QPointF(57, 50));
QVERIFY(still->finishBrushImmediately());
QVERIFY(ink(*still, 56, 50) > 0);
QCOMPARE(ink(*still, 62, 50), 0);
}
void BrushSmoothingTests::theStringIsMeasuredOnScreen()
{
const auto session = painting(10);
session->viewport.setZoom(2, QPointF(), session->document().value().size());
session->beginBrush(QPointF(50, 50));
session->continueBrush(QPointF(60, 50));
QCOMPARE(painted(*session), QPointF(55, 50));
}
void BrushSmoothingTests::onlyTheBrushSmoothsAndACancelLetsGo()
{
const auto healing = painting(10, NavigationTool::spotHealing);
healing->beginBrush(QPointF(50, 50));
healing->continueBrush(QPointF(52, 50));
QCOMPARE(painted(*healing), QPointF(52, 50));
// The eraser is the Brush in another mode: it smooths.
const auto erasing = painting(10);
erasing->setBrushMode(BrushToolMode::erase);
erasing->beginBrush(QPointF(50, 50));
erasing->continueBrush(QPointF(55, 50));
QCOMPARE(painted(*erasing), QPointF(50, 50));
// A cancelled stroke leaves no anchor for the next.
const auto session = painting(10);
session->beginBrush(QPointF(50, 50));
session->continueBrush(QPointF(80, 50));
session->cancelBrush();
session->beginBrush(QPointF(100, 100));
session->continueBrush(QPointF(115, 100));
QCOMPARE(painted(*session), QPointF(105, 100));
// A cancel drops the hand's point: nothing lands.
session->cancelBrush();
QVERIFY(session->finishBrushImmediately());
}
QTEST_GUILESS_MAIN(BrushSmoothingTests)
#include "BrushSmoothingTests.moc"
+4 -1
View File
@@ -119,7 +119,7 @@ void EffectsPanelTests::theFooterAddsEachEffectAndOpensItsPanel()
QStringList names;
for (const QAction *entry : button.menu()->actions())
names << entry->text();
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…"}));
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…", "Outer Glow…"}));
QVERIFY(button.isEnabled());
button.menu()->actions()[0]->trigger();
QVERIFY((editor.session.effectsEditing() == LayerEffectSelection{editor.layer, LayerEffectKind::stroke}));
@@ -291,6 +291,7 @@ void EffectsPanelTests::eachKindHasSwiftsControls_data()
QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100:100"};
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow")
<< QStringList{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:50", "Blur:0:500:100"};
QTest::newRow("outer glow") << LayerEffectKind::outerGlow << QString("Outer Glow") << QStringList{"Size:0:500:100", "Opacity:0:100:100"};
}
void EffectsPanelTests::eachKindHasSwiftsControls()
@@ -309,6 +310,8 @@ void EffectsPanelTests::eachKindHasSwiftsControls()
head = label;
}
QVERIFY(head && head->font().pixelSize() == 13 && head->font().weight() == QFont::DemiBold);
// The stroke chooses its side; the rest show their colour.
QCOMPARE(sheet.findChild<QWidget *>("strokePosition") != nullptr, kind == LayerEffectKind::stroke);
// Swift's fields in order, each clamped to its range's ends.
QStringList names;
for (const QLineEdit *field : sheet.findChildren<QLineEdit *>())
+6 -3
View File
@@ -144,10 +144,11 @@ void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt()
// Same kinds: the old preview shows while the new renders.
layer.effects = outline(5);
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), first);
QCOMPARE(landed(cache, layer, landings, 2).inset, 7.0);
// Another kind: nothing is shown until it lands.
const EffectsPreviewCache::Result second = landed(cache, layer, landings, 2);
QCOMPARE(second.inset, 7.0);
// Another kind shown or hidden: the last preview stands.
layer.effects->colorOverlay = ColorOverlayEffect();
QVERIFY(!ask(cache, layer, landings));
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), second.image.cacheKey());
landed(cache, layer, landings, 3);
// New pixels, or a mask switched on, drop it too.
layer.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red"));
@@ -318,6 +319,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
effects.shadow = ShadowEffect{.angle = 45, .distance = 8, .blur = 3, .red = 1, .green = 0, .blue = 0, .opacity = 0.8};
effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5};
effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 30, .blur = 12, .red = 0, .green = 0, .blue = 0, .opacity = 0.7};
effects.outerGlow = OuterGlowEffect{.size = 5, .red = 0, .green = 0, .blue = 1, .opacity = 0.6};
const QImage image = noise(6000, 100, 7);
const ImageLayer layer = layerOf(image, effects);
const Landings landings;
@@ -332,6 +334,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
effects.shadow->blur *= factor;
effects.innerShadow->distance *= factor;
effects.innerShadow->blur *= factor;
effects.outerGlow->size *= factor;
QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image);
}
+17 -3
View File
@@ -88,7 +88,7 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
}
const auto on = [](const auto &effect) { return effect && effect->isEnabled() && effect->opacity > 0; };
const bool stroke = on(effects.stroke) && effects.stroke->size > 0;
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size());
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size());
if (stroke) {
const int reach = std::max(1, int(std::lround(effects.stroke->size)));
const std::vector<float> moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke->inside, true), reach, effects.stroke->inside, false);
@@ -98,6 +98,9 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
if (on(effects.shadow))
cast = shader.softened(shader.shift(shader.shape, float(effects.shadow->offset().width()), float(effects.shadow->offset().height())),
float(effects.shadow->blur / 2));
const bool glowing = on(effects.outerGlow) && effects.outerGlow->size > 0;
if (glowing)
glow = shader.softened(shader.shape, float(effects.outerGlow->size / 2));
if (on(effects.innerShadow)) {
const std::vector<float> moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow->offset().width()),
float(effects.innerShadow->offset().height())),
@@ -118,6 +121,8 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
};
if (on(effects.shadow))
blend(effects.shadow->color(), std::clamp(cast[index] * float(effects.shadow->opacity), 0.f, 1.f));
if (glowing)
blend(effects.outerGlow->color(), std::clamp(glow[index] * (1.f - shader.shape[index]) * float(effects.outerGlow->opacity), 0.f, 1.f));
const float strokeCoverage = stroke ? std::clamp(ring[index] * float(effects.stroke->opacity), 0.f, 1.f) : 0.f;
if (stroke && !effects.stroke->inside)
blend(effects.stroke->color(), strokeCoverage);
@@ -189,16 +194,19 @@ private slots:
void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
{
QTest::addColumn<LayerEffects>("effects");
LayerEffects outside, inside, shadow, overlay, inner, all, wide;
LayerEffects outside, inside, shadow, overlay, inner, glow, hair, all, wide;
outside.stroke = StrokeEffect{.size = 3, .red = 1, .green = 0.5, .blue = 0.25, .opacity = 0.8};
inside.stroke = StrokeEffect{.size = 2.5, .red = 0.1, .green = 0.2, .blue = 0.9, .opacity = 1, .inside = true};
shadow.shadow = ShadowEffect{.angle = 45, .distance = 3.5, .blur = 4, .red = 0.3, .green = 0.1, .blue = 0.6, .opacity = 0.75};
overlay.colorOverlay = ColorOverlayEffect{.red = 0.9, .green = 0.8, .blue = 0.1, .opacity = 0.5};
inner.innerShadow = InnerShadowEffect{.angle = -60, .distance = 2, .blur = 3, .red = 0, .green = 0.4, .blue = 0.2, .opacity = 0.9};
glow.outerGlow = OuterGlowEffect{.size = 6, .red = 0.2, .green = 0.9, .blue = 0.4, .opacity = 0.8};
hair.outerGlow = OuterGlowEffect{.size = 0.01, .opacity = 1};
all = outside;
all.shadow = shadow.shadow;
all.colorOverlay = overlay.colorOverlay;
all.innerShadow = inner.innerShadow;
all.outerGlow = glow.outerGlow;
all.shadow->blur = 0;
wide.stroke = StrokeEffect{.size = 9, .red = 1, .opacity = 1};
wide.shadow = ShadowEffect{.angle = 200, .distance = 0.5, .blur = 0.01, .opacity = 1};
@@ -207,6 +215,8 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
QTest::newRow("drop shadow") << shadow;
QTest::newRow("colour overlay") << overlay;
QTest::newRow("inner shadow") << inner;
QTest::newRow("outer glow") << glow;
QTest::newRow("a glow a hair wide") << hair;
QTest::newRow("all, a sharp shadow") << all;
QTest::newRow("wide stroke, a barely blurred shadow") << wide;
}
@@ -233,11 +243,15 @@ void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels()
hidden.shadow = ShadowEffect{.enabled = false, .distance = 2, .blur = 0, .opacity = 1};
hidden.colorOverlay = ColorOverlayEffect{.enabled = false, .red = 1};
hidden.innerShadow = InnerShadowEffect{.enabled = false};
hidden.outerGlow = OuterGlowEffect{.enabled = false};
LayerEffects dim, flat;
dim.outerGlow = OuterGlowEffect{.opacity = 0};
flat.outerGlow = OuterGlowEffect{.size = 0};
LayerEffects thin;
thin.stroke = StrokeEffect{.size = 0, .red = 1};
LayerEffects clear;
clear.shadow = ShadowEffect{.opacity = 0};
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear})
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear, dim, flat})
QCOMPARE(gap(LayerEffectsKernel::render(pixels, effects), pixels), 0);
// Stroke sizes round to whole pixels, one at least.
LayerEffects fraction, one;
+3 -1
View File
@@ -20,17 +20,19 @@ void addRows()
{
QTest::addColumn<LayerEffects>("effects");
const LayerEffects all = everyKind();
LayerEffects outside, inside, cast, overlay, inner;
LayerEffects outside, inside, cast, overlay, inner, glow;
outside.stroke = all.stroke;
inside.stroke = StrokeEffect{.size = 10, .red = 1, .green = 0, .blue = 1, .opacity = 0.9, .inside = true};
cast.shadow = all.shadow;
overlay.colorOverlay = all.colorOverlay;
inner.innerShadow = all.innerShadow;
glow.outerGlow = OuterGlowEffect{.size = 12, .red = 0, .green = 1, .blue = 1, .opacity = 0.9};
QTest::newRow("outside stroke") << outside;
QTest::newRow("inside stroke") << inside;
QTest::newRow("shadow") << cast;
QTest::newRow("overlay") << overlay;
QTest::newRow("inner shadow") << inner;
QTest::newRow("outer glow") << glow;
QTest::newRow("every kind") << all;
}
+8 -3
View File
@@ -24,6 +24,7 @@ LayerEffects every()
effects.shadow = ShadowEffect{.angle = 45, .distance = 12, .blur = 6, .red = 0.5, .green = 0.6, .blue = 0.7, .opacity = 0.8};
effects.colorOverlay = ColorOverlayEffect{.enabled = true, .red = 0.9, .green = 0.25, .blue = 0.75, .opacity = 0.35};
effects.innerShadow = InnerShadowEffect{.angle = -30, .distance = 3, .blur = 2, .red = 0.05, .green = 0.15, .blue = 0.95, .opacity = 0.65};
effects.outerGlow = OuterGlowEffect{.size = 33, .red = 0.2, .green = 0.4, .blue = 0.6, .opacity = 0.55};
return effects;
}
@@ -79,7 +80,8 @@ private slots:
void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
{
const std::pair<LayerEffectKind, const char *> kinds[] = {{LayerEffectKind::stroke, "Stroke"}, {LayerEffectKind::shadow, "Drop Shadow"},
{LayerEffectKind::colorOverlay, "Color Overlay"}, {LayerEffectKind::innerShadow, "Inner Shadow"}};
{LayerEffectKind::colorOverlay, "Color Overlay"}, {LayerEffectKind::innerShadow, "Inner Shadow"},
{LayerEffectKind::outerGlow, "Outer Glow"}};
QCOMPARE(allLayerEffectKinds.size(), std::size(kinds));
for (size_t index = 0; index < std::size(kinds); ++index) {
QVERIFY(allLayerEffectKinds[index] == kinds[index].first);
@@ -88,6 +90,7 @@ void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
// Swift's defaults.
QVERIFY((StrokeEffect() == StrokeEffect{std::nullopt, 4, 0, 0, 0, 1, false}));
QVERIFY((ShadowEffect() == ShadowEffect{std::nullopt, 90, 20, 20, 0, 0, 0, 0.5}));
QVERIFY((OuterGlowEffect() == OuterGlowEffect{std::nullopt, 20, 1, 1, 1, 0.75}));
QVERIFY((ColorOverlayEffect() == ColorOverlayEffect{std::nullopt, 0, 0, 0, 1}));
QVERIFY((InnerShadowEffect() == InnerShadowEffect{std::nullopt, 90, 10, 10, 0, 0, 0, 0.5}));
QCOMPARE(StrokeEffect::maxSize, 500.0);
@@ -165,7 +168,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
for (const LayerEffectKind kind : allLayerEffectKinds) {
LayerEffects full = every();
full.remove(kind);
QVERIFY(!full.contains(kind) && full.kinds().size() == 3);
QVERIFY(!full.contains(kind) && full.kinds().size() == 4);
full.setColor(PaletteColor{1, 0, 0}, kind);
QVERIFY(!full.color(kind));
}
@@ -190,7 +193,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
void LayerEffectsTests::theManifestWritesSwiftsKeys()
{
const QJsonObject encoded = ManifestJson::encoded(every());
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerShadow", "shadow", "stroke"}));
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerShadow", "outerGlow", "shadow", "stroke"}));
const QJsonObject stroke = encoded.value("stroke").toObject();
QCOMPARE(stroke.keys(), (QStringList{"blue", "enabled", "green", "inside", "opacity", "red", "size"}));
QVERIFY(stroke.value("enabled") == false && stroke.value("inside") == true && stroke.value("size") == 7);
@@ -198,6 +201,8 @@ void LayerEffectsTests::theManifestWritesSwiftsKeys()
QCOMPARE(encoded.value("shadow").toObject().keys(), (QStringList{"angle", "blue", "blur", "distance", "green", "opacity", "red"}));
QCOMPARE(encoded.value("colorOverlay").toObject().keys(), (QStringList{"blue", "enabled", "green", "opacity", "red"}));
QCOMPARE(encoded.value("innerShadow").toObject().value("angle"), QJsonValue(-30));
QCOMPARE(encoded.value("outerGlow").toObject().keys(), (QStringList{"blue", "green", "opacity", "red", "size"}));
QCOMPARE(encoded.value("outerGlow").toObject().value("size"), QJsonValue(33));
QVERIFY(ManifestJson::effects(encoded) == every());
// Absent effects stay out; null reads as absent.
LayerEffects one;
+233
View File
@@ -0,0 +1,233 @@
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore+Json.h"
#include <QPainter>
#include <QtTest>
// Swift's OuterGlowTests: the glow's model, drawing and export.
namespace {
// A white square centred on clear, Swift's createSquareImage.
QImage square(int size = 40, int inner = 20)
{
QImage image(size, size, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::transparent);
QPainter painter(&image);
const int origin = (size - inner) / 2;
painter.fillRect(QRect(origin, origin, inner, inner), Qt::white);
return image;
}
QColor at(const LayerEffectsRenderer::Rendered &rendered, int x, int y)
{
const int inset = int(rendered.inset);
return rendered.image.pixelColor(inset + x, inset + y);
}
LayerEffects glowing(double size, double red, double green, double blue, double opacity)
{
LayerEffects effects;
effects.outerGlow = OuterGlowEffect{.enabled = true, .size = size, .red = red, .green = green, .blue = blue, .opacity = opacity};
return effects;
}
}
class OuterGlowTests : public QObject {
Q_OBJECT
private slots:
void outerGlowDefaultsAndValidation();
void outerGlowCodableRoundTrip();
void layerEffectsIntegration();
void layerEffectsCodableBackwardCompatibility();
void outerGlowRendersOmnidirectionally();
void outerGlowSizeAndOpacityVariations();
void outerGlowRendersAroundTextGlyphs();
void outerGlowCombinedWithStrokeAndDropShadow();
void outerGlowPreservedInExport();
void theGlowWidensTheMargin();
void theSessionAddsCopiesAndCancelsIt();
};
void OuterGlowTests::outerGlowDefaultsAndValidation()
{
const OuterGlowEffect glow;
QVERIFY(glow.isEnabled() && glow.isValid());
QCOMPARE(glow.size, 20.0);
QCOMPARE(glow.opacity, 0.75);
QVERIFY(glow.color() == (PaletteColor{1, 1, 1}));
for (const auto &broken : std::vector<std::function<void(OuterGlowEffect &)>>{
[](OuterGlowEffect &e) { e.size = -1; }, [](OuterGlowEffect &e) { e.size = 500.5; }, [](OuterGlowEffect &e) { e.size = qQNaN(); },
[](OuterGlowEffect &e) { e.opacity = 1.5; }, [](OuterGlowEffect &e) { e.red = 2; }, [](OuterGlowEffect &e) { e.green = -0.1; },
[](OuterGlowEffect &e) { e.blue = qInf(); }}) {
OuterGlowEffect changed = glow;
broken(changed);
QVERIFY(!changed.isValid());
LayerEffects effects;
effects.outerGlow = changed;
QVERIFY(!effects.isValid());
}
OuterGlowEffect edge = glow;
edge.size = 500;
QVERIFY(edge.isValid());
}
void OuterGlowTests::outerGlowCodableRoundTrip()
{
const LayerEffects original = glowing(35, 0.2, 0.8, 1, 0.6);
const QJsonObject encoded = ManifestJson::encoded(original);
const QJsonObject glow = encoded.value("outerGlow").toObject();
QCOMPARE(glow.keys(), (QStringList{"blue", "enabled", "green", "opacity", "red", "size"}));
QCOMPARE(ManifestJson::effects(encoded), original);
// Absent `enabled` stays absent, read as shown.
LayerEffects shown = original;
shown.outerGlow->enabled = std::nullopt;
QVERIFY(!ManifestJson::encoded(shown).value("outerGlow").toObject().contains("enabled"));
QCOMPARE(ManifestJson::effects(ManifestJson::encoded(shown)), shown);
// A key of the wrong type, or missing, refuses it.
QJsonObject wrong = encoded;
QJsonObject bent = glow;
bent.insert("size", "big");
wrong.insert("outerGlow", bent);
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
bent = glow;
bent.remove("opacity");
wrong.insert("outerGlow", bent);
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
}
void OuterGlowTests::layerEffectsIntegration()
{
LayerEffects effects;
QVERIFY(effects.isEmpty() && !effects.contains(LayerEffectKind::outerGlow));
effects.outerGlow = OuterGlowEffect{.size = 25, .red = 1, .green = 0, .blue = 0, .opacity = 0.8};
QVERIFY(!effects.isEmpty() && effects.contains(LayerEffectKind::outerGlow) && effects.isEnabled(LayerEffectKind::outerGlow));
QVERIFY(effects.color(LayerEffectKind::outerGlow) == (PaletteColor{1, 0, 0}));
QVERIFY((effects.kinds() == std::vector{LayerEffectKind::outerGlow}));
effects.setColor(PaletteColor{0, 1, 0}, LayerEffectKind::outerGlow);
QVERIFY(effects.outerGlow->green == 1 && effects.outerGlow->red == 0);
effects.setEnabled(false, LayerEffectKind::outerGlow);
QVERIFY(!effects.isEnabled(LayerEffectKind::outerGlow) && !effects.visible().outerGlow);
effects.remove(LayerEffectKind::outerGlow);
QVERIFY(!effects.outerGlow && effects.isEmpty());
}
void OuterGlowTests::layerEffectsCodableBackwardCompatibility()
{
// Effects written before the glow read without it.
const QJsonObject older{{"stroke", QJsonObject{{"size", 3}, {"red", 0}, {"green", 0}, {"blue", 0}, {"opacity", 1}, {"inside", false}}}};
LayerEffects decoded = ManifestJson::effects(older);
QCOMPARE(decoded.stroke.value().size, 3.0);
QVERIFY(!decoded.outerGlow && decoded.isValid());
decoded.outerGlow = OuterGlowEffect{.size = 15, .red = 1, .green = 0.5, .blue = 0, .opacity = 0.9};
const LayerEffects again = ManifestJson::effects(ManifestJson::encoded(decoded));
QCOMPARE(again.outerGlow.value().size, 15.0);
QCOMPARE(again.outerGlow.value().opacity, 0.9);
}
void OuterGlowTests::outerGlowRendersOmnidirectionally()
{
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(square(), std::nullopt, glowing(10, 0, 1, 0, 1));
QCOMPARE(rendered.inset, 32.0);
// The square keeps its white; the glow shows all round.
const QColor centre = at(rendered, 20, 20);
QVERIFY(centre.alphaF() > 0.95 && centre.redF() > 0.95 && centre.greenF() > 0.95 && centre.blueF() > 0.95);
const QColor sides[] = {at(rendered, 5, 20), at(rendered, 35, 20), at(rendered, 20, 5), at(rendered, 20, 35)};
for (const QColor &side : sides) {
QVERIFY(side.alphaF() > 0.1);
QVERIFY(side.greenF() > 0.8 && side.redF() < 0.05);
QVERIFY(std::abs(side.alphaF() - sides[0].alphaF()) < 0.05);
}
// Nothing reaches past three sigmas and a pixel.
QCOMPARE(at(rendered, -17, 20).alpha(), 0);
}
void OuterGlowTests::outerGlowSizeAndOpacityVariations()
{
const QImage image = square();
const LayerEffectsRenderer::Rendered small = LayerEffectsRenderer::render(image, std::nullopt, glowing(4, 1, 0, 0, 1));
const LayerEffectsRenderer::Rendered large = LayerEffectsRenderer::render(image, std::nullopt, glowing(20, 1, 0, 0, 1));
QVERIFY(large.inset > small.inset);
QVERIFY(at(large, 2, 20).alphaF() > at(small, 2, 20).alphaF());
const LayerEffectsRenderer::Rendered low = LayerEffectsRenderer::render(image, std::nullopt, glowing(10, 0, 0, 1, 0.2));
const LayerEffectsRenderer::Rendered high = LayerEffectsRenderer::render(image, std::nullopt, glowing(10, 0, 0, 1, 1));
QVERIFY(at(high, 5, 20).alphaF() > at(low, 5, 20).alphaF());
}
void OuterGlowTests::outerGlowRendersAroundTextGlyphs()
{
// A T: a bar over a stem, white on clear.
QImage glyph(60, 60, QImage::Format_RGBA8888_Premultiplied);
glyph.fill(Qt::transparent);
QPainter painter(&glyph);
painter.fillRect(QRect(15, 15, 30, 8), Qt::white);
painter.fillRect(QRect(26, 23, 8, 22), Qt::white);
painter.end();
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(glyph, std::nullopt, glowing(8, 0, 1, 1, 1));
const QColor stem = at(rendered, 30, 30), above = at(rendered, 30, 12), notch = at(rendered, 20, 27);
QVERIFY(stem.alphaF() > 0.9 && stem.redF() > 0.9 && stem.greenF() > 0.9 && stem.blueF() > 0.9);
QVERIFY(above.alphaF() > 0.05 && above.greenF() > 0.5 && above.blueF() > 0.5);
QVERIFY(notch.alphaF() > 0.05 && notch.greenF() > 0.5 && notch.blueF() > 0.5);
}
void OuterGlowTests::outerGlowCombinedWithStrokeAndDropShadow()
{
LayerEffects effects = glowing(10, 1, 0, 0, 1);
effects.stroke = StrokeEffect{.size = 3, .red = 0, .green = 0, .blue = 0, .opacity = 1, .inside = false};
effects.shadow = ShadowEffect{.enabled = true, .angle = 180, .distance = 25, .blur = 4, .red = 0, .green = 0, .blue = 1, .opacity = 1};
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(square(50, 20), std::nullopt, effects);
const QColor centre = at(rendered, 25, 25);
QVERIFY(centre.redF() > 0.9 && centre.greenF() > 0.9 && centre.blueF() > 0.9);
// The stroke lies over the glow, the glow over shadow.
const QColor stroke = at(rendered, 13, 25);
QVERIFY(stroke.alphaF() > 0.9 && stroke.redF() < 0.2 && stroke.greenF() < 0.2 && stroke.blueF() < 0.2);
const QColor glow = at(rendered, 10, 25);
QVERIFY(glow.alphaF() > 0.05 && glow.redF() > 0.6);
const QColor shadow = at(rendered, 50, 25);
QVERIFY(shadow.alphaF() > 0.1 && shadow.blueF() > 0.6);
}
void OuterGlowTests::outerGlowPreservedInExport()
{
EditorSession session;
session.createDocument(100, 100);
session.insert(ImportedImage(square(30, 10), QImage(), QStringLiteral("GlowLayer")), QPointF(35, 35));
session.setEffects(glowing(15, 1, 0.5, 0, 1));
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
QVERIFY(exported.pixelColor(35, 35).alphaF() > 0.9);
const QColor glow = exported.pixelColor(27, 35);
QVERIFY(glow.alphaF() > 0.1 && glow.redF() > 0.6);
}
void OuterGlowTests::theGlowWidensTheMargin()
{
LayerEffects effects = glowing(7.2, 1, 1, 1, 1);
QCOMPARE(LayerEffectsRenderer::margin(effects), 24.0);
effects.outerGlow->enabled = false;
QCOMPARE(LayerEffectsRenderer::margin(effects), 2.0);
}
void OuterGlowTests::theSessionAddsCopiesAndCancelsIt()
{
EditorSession session;
session.createDocument(40, 40);
session.insert(ImportedImage(square(), QImage(), QStringLiteral("One")));
const QUuid first = session.activeLayerID().value();
session.addEffect(LayerEffectKind::outerGlow);
QCOMPARE(session.history.undoName(), QString("Add Outer Glow"));
QVERIFY(session.activeEffects().outerGlow == OuterGlowEffect());
session.changeEffects([](LayerEffects &effects) { effects.outerGlow->size = 44; });
session.finishEffectsEditing(false);
QVERIFY(!session.activeEffects().outerGlow);
session.addEffect(LayerEffectKind::outerGlow);
session.changeEffects([](LayerEffects &effects) { effects.outerGlow->size = 44; });
session.finishEffectsEditing(true);
session.insert(ImportedImage(square(), QImage(), QStringLiteral("Two")));
const QUuid second = session.activeLayerID().value();
QVERIFY(session.canCopyEffect(LayerEffectKind::outerGlow, first, second));
session.copyEffect(LayerEffectKind::outerGlow, first, second);
QCOMPARE(session.history.undoName(), QString("Copy Outer Glow"));
QCOMPARE(session.activeEffects().outerGlow.value().size, 44.0);
}
QTEST_MAIN(OuterGlowTests)
#include "OuterGlowTests.moc"
+26 -4
View File
@@ -1,4 +1,6 @@
#include "MenuFixtures.h"
#include "Rendering/EditorCanvas.h"
#include "UI/NativeLayerList.h"
#include <QApplication>
#include <QLineEdit>
#include <QRegularExpression>
@@ -23,9 +25,12 @@ private slots:
void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt()
{
Bar bar;
for (const char *name : {"selectAll", "deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection",
"featherSelection"})
for (const char *name : {"deselect", "inverse", "layerPixels", "subject", "maskBlackAreas", "expandSelection", "contractSelection", "featherSelection"})
QVERIFY2(!bar.action(name).isEnabled(), name);
// Select All never rests; without a canvas it does nothing.
QVERIFY(bar.action("selectAll").isEnabled());
bar.action("selectAll").trigger();
QVERIFY(!bar.session().document());
bar.session().createDocument(50, 50, true);
QVERIFY(bar.action("subject").isEnabled());
QVERIFY(bar.action("selectAll").isEnabled());
@@ -95,10 +100,14 @@ void SelectMenuTests::selectAllInAFieldIsTheFields()
Bar bar;
bar.window.show();
QVERIFY(QTest::qWaitForWindowExposed(&bar.window));
// Without a canvas the entry rests; the welcome keeps Ctrl+A.
// Without a canvas the entry selects the welcome's field.
QLineEdit &width = *bar.window.findChild<QLineEdit *>("widthInput");
QTRY_VERIFY(width.hasFocus());
QVERIFY(!bar.action("selectAll").isEnabled());
width.setText(QStringLiteral("640"));
width.deselect();
QVERIFY(bar.action("selectAll").isEnabled());
bar.action("selectAll").trigger();
QCOMPARE(width.selectedText(), QString("640"));
bar.session().createDocument(50, 50, true);
QTRY_VERIFY(bar.action("selectAll").isEnabled());
// A rename in progress: the editor's text, never the canvas.
@@ -112,6 +121,19 @@ void SelectMenuTests::selectAllInAFieldIsTheFields()
QVERIFY(!bar.session().selection().has_value());
QTest::keyClick(editor, Qt::Key_Escape);
QTRY_VERIFY(!bar.session().renamingLayerID().has_value());
// The list holds the keys: its rows, as Swift's table.
bar.session().addBlankLayer();
NativeLayerList *list = bar.window.findChild<NativeLayerList *>();
list->setFocus();
QTRY_VERIFY(list->hasFocus());
const std::vector<ImageLayer> &layers = bar.session().document().value().layers;
bar.action("selectAll").trigger();
QCOMPARE(bar.session().selectedLayerIDs(), (QSet<QUuid>{layers[0].id, layers[1].id}));
QCOMPARE(bar.session().activeLayerID(), std::optional(layers[1].id));
QVERIFY(!bar.session().selection().has_value());
// Elsewhere it selects the canvas.
bar.window.findChild<CanvasView *>()->setFocus();
QTRY_VERIFY(!list->hasFocus());
bar.action("selectAll").trigger();
QVERIFY(bar.session().selection().has_value());
}
+3 -8
View File
@@ -3,7 +3,6 @@
#include "AddressSpaceLimit.h"
#include "RenderFixtures.h"
#include "SelectionFixtures.h"
#include <QElapsedTimer>
// Masks from a selection, the delete target, and Invert.
namespace {
@@ -61,7 +60,7 @@ private slots:
void maskFromSelectionLinesUpOnScaledLayers();
void deletingWithTheMaskTargetedRemovesOnlyTheMask();
void invertKeepsTransparencyStaysInSelectionAndWorksOnMasks();
void invertIsFastOnLargeImagesAndHandlesUniformMasksWithASelection();
void invertHandlesLargeImagesAndUniformMasksWithASelection();
void invertWorksInEveryTool();
void invertIsRefusedWhereSwiftRefusesIt();
void anInvertComputedFromOtherPixelsIsDropped();
@@ -242,20 +241,16 @@ void SelectionEditTests::invertKeepsTransparencyStaysInSelectionAndWorksOnMasks(
QCOMPARE(int(placed.constScanLine(20)[75]), 255);
}
void SelectionEditTests::invertIsFastOnLargeImagesAndHandlesUniformMasksWithASelection()
void SelectionEditTests::invertHandlesLargeImagesAndUniformMasksWithASelection()
{
const auto session = editSession(4000, 3000);
QImage image = BrushRaster::context(4000, 3000, false);
image.fill(Qt::red);
session->insert(ImportedImage(image, image, "Big"));
QElapsedTimer clock;
clock.start();
// No clock: in a parallel suite it measures load (7c4512a).
invert(*session);
const qint64 whole = clock.restart();
select(*session, QRectF(0, 0, 2000, 3000));
invert(*session);
const qint64 selected = clock.elapsed();
QVERIFY2(whole < 1500 && selected < 1500, qPrintable(QStringLiteral("whole %1 ms, selected %2 ms").arg(whole).arg(selected)));
const QImage result = render(*session);
// Inverted twice, then once.
QCOMPARE(pixel(result, 100, 100), (std::vector<int>{255, 0, 0, 255}));
+1
View File
@@ -51,6 +51,7 @@ inline QStringList described(const EditorSession &session)
result << QStringLiteral("brush %1 %2 %3 rgb %4 %5 %6 erase %7 mode %8 white %9 stroke %10 revision %11").arg(tip.diameter).arg(tip.hardness).arg(tip.opacity)
.arg(tip.red).arg(tip.green).arg(tip.blue).arg(int(tip.erasing)).arg(rawValue(session.brushMode())).arg(int(session.maskPaintWhite()))
.arg(session.brushStroke() ? int(session.brushStroke()->patches().size()) : -1).arg(session.brushRevision());
result << QStringLiteral("smoothing %1").arg(tip.smoothing);
if (session.filterEdit().has_value()) {
const FilterEdit &edit = session.filterEdit().value();
result << QStringLiteral("filter %1 %2 preview %3 preparing %4 committing %5 error %6 grown %7").arg(rawValue(edit.kind), edit.layerID.toString())