Typed text drawn as its pixels; effects kept while typing, painting masks and undoing (12.12a)

This commit is contained in:
雷电芽衣 committed 2026-09-29 12:47:36 -04:00
1 parent 8fbb104c48
commit 136ee65a48
23 files changed
+1125 -153

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "Document/LayerEffects+Renderer.h"
#include "IO/ImageExporter.h"
#include "Rendering/EditorCanvas.h"
#include "RenderFixtures.h"
#include "SessionFixtures.h"
#include <QtTest>
// A square with a stroke effect, on a white canvas.
namespace {
const QColor yellow(255, 255, 0);
LayerEffects outline(double size)
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// White document, a square 25..35 by 15..25, at 100%.
struct Scene {
EditorSession session;
CanvasView canvas{session};
QUuid square;
explicit Scene(QRgb colour = qRgba(0, 0, 255, 255))
{
session.createDocument(60, 40);
session.setShowsTransformControls(false);
canvas.resize(300, 200);
session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40));
session.zoom(1);
const QImage white = solid(60, 40, qRgba(255, 255, 255, 255));
session.insert(ImportedImage(white, white, QStringLiteral("White")));
const QImage pixels = solid(10, 10, colour);
session.insert(ImportedImage(pixels, pixels, QStringLiteral("Square")), QPointF(30, 20));
square = session.activeLayerID().value();
}
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
// The colour shown at a document pixel's centre.
QColor at(QPoint pixel)
{
return shot().pixelColor(shown(QPointF(pixel) + QPointF(0.5, 0.5)));
}
// The view pixel holding a document point.
QPoint shown(QPointF point)
{
const QPointF view = session.viewport.viewPoint(point, QSizeF(60, 40));
return QPoint(int(std::floor(view.x())), int(std::floor(view.y())));
}
// Drawn once, then the worker's preview awaited.
EffectsPreviewCache::Result landed()
{
shot();
if (!QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 5000))
throw std::runtime_error("no preview landed");
return session.effectsPreviews.rendered(square).value();
}
// The worst channel gap to the export, pixel by pixel.
int gapToExport()
{
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
const QImage image = shot();
int worst = 0;
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 60; ++x) {
const QRgb expected = exported.pixel(x, y);
const QRgb actual = image.pixel(shown(QPointF(x + 0.5, y + 0.5)));
worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)),
std::abs(qBlue(expected) - qBlue(actual))});
}
}
return worst;
}
};
}
+1 -65
View File
@@ -2,76 +2,12 @@
#include "IO/ImageExporter.h"
#include "Rendering/EditorCanvas.h"
#include "RenderFixtures.h"
#include "EffectsCanvasFixtures.h"
#include "SessionFixtures.h"
#include <QtTest>
// Swift's effects on the canvas: previews, the paint's surface, distortions.
namespace {
const QColor yellow(255, 255, 0);
LayerEffects outline(double size)
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// White document, a square 25..35 by 15..25, at 100%.
struct Scene {
EditorSession session;
CanvasView canvas{session};
QUuid square;
explicit Scene(QRgb colour = qRgba(0, 0, 255, 255))
{
session.createDocument(60, 40);
session.setShowsTransformControls(false);
canvas.resize(300, 200);
session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40));
session.zoom(1);
const QImage white = solid(60, 40, qRgba(255, 255, 255, 255));
session.insert(ImportedImage(white, white, QStringLiteral("White")));
const QImage pixels = solid(10, 10, colour);
session.insert(ImportedImage(pixels, pixels, QStringLiteral("Square")), QPointF(30, 20));
square = session.activeLayerID().value();
}
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
// The colour shown at a document pixel's centre.
QColor at(QPoint pixel)
{
return shot().pixelColor(shown(QPointF(pixel) + QPointF(0.5, 0.5)));
}
// The view pixel holding a document point.
QPoint shown(QPointF point)
{
const QPointF view = session.viewport.viewPoint(point, QSizeF(60, 40));
return QPoint(int(std::floor(view.x())), int(std::floor(view.y())));
}
// Drawn once, then the worker's preview awaited.
EffectsPreviewCache::Result landed()
{
shot();
if (!QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 5000))
throw std::runtime_error("no preview landed");
return session.effectsPreviews.rendered(square).value();
}
// The worst channel gap to the export, pixel by pixel.
int gapToExport()
{
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32);
const QImage image = shot();
int worst = 0;
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 60; ++x) {
const QRgb expected = exported.pixel(x, y);
const QRgb actual = image.pixel(shown(QPointF(x + 0.5, y + 0.5)));
worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)),
std::abs(qBlue(expected) - qBlue(actual))});
}
}
return worst;
}
};
// Counts the paints a widget receives.
struct Painted : QObject {
int &count;
+73 -6
View File
@@ -68,6 +68,8 @@ private slots:
void aMaskHidesPixelsInTheReducedPreview();
void aReducedPreviewScalesEveryLength();
void aClosedCacheLandsNothing();
void undoneStepsReuseTheirEffects();
void renderNowRendersAtThePreviewSize();
};
void EffectsPreviewCacheTests::aPreviewLandsAfterItsDelayAsTheRendererDrawsIt()
@@ -150,17 +152,82 @@ void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt()
layer.effects->colorOverlay = ColorOverlayEffect();
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.
// New pixels drop it.
layer.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red"));
QVERIFY(!ask(cache, layer, landings));
landed(cache, layer, landings, 4);
const qint64 red = landed(cache, layer, landings, 4).image.cacheKey();
// A mask added or removed on those pixels keeps it.
layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255)));
QVERIFY(!ask(cache, layer, landings, layer.mask->enabledImage()));
landed(cache, layer, landings, 5);
// A mask switched off is no source.
QCOMPARE(ask(cache, layer, landings, layer.mask->enabledImage()).value().image.cacheKey(), red);
QVERIFY(landed(cache, layer, landings, 5).image.cacheKey() != red);
// Switched off again, the unmasked effects are known: no render.
layer.mask->isEnabled = false;
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), red);
QTest::qWait(200);
QCOMPARE(*landings.count, 5);
}
void EffectsPreviewCacheTests::undoneStepsReuseTheirEffects()
{
EffectsPreviewCache cache;
ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3));
const Landings landings;
// Four pixels in turn, each rendered and landed.
std::vector<ImportedImage> images;
std::vector<qint64> keys;
for (int step = 0; step < 4; ++step) {
images.emplace_back(filled(20, 10, QColor(0, 0, 60 + 60 * step)), QImage(), QStringLiteral("Step"));
layer.asset = images.back();
QVERIFY(!ask(cache, layer, landings));
keys.push_back(landed(cache, layer, landings, step + 1).image.cacheKey());
}
// Undone and redone across draws: the three newest are known.
for (const int step : {2, 1, 3}) {
cache.prepare({layer});
layer.asset = images[size_t(step)];
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), keys[size_t(step)]);
}
QTest::qWait(200);
QCOMPARE(*landings.count, 4);
// Undone and redone across draws: three newest are known.
cache.seed(layer.id, filled(4, 4, QColor(0, 255, 0)), layer.transform);
layer.asset = images[2];
QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), keys[2]);
layer.asset = images[0];
QVERIFY(!ask(cache, layer, landings));
landed(cache, layer, landings, 6);
// A layer without effects forgets them.
cache.prepare({});
layer.asset = images[3];
QVERIFY(!ask(cache, layer, landings));
}
void EffectsPreviewCacheTests::renderNowRendersAtThePreviewSize()
{
EffectsPreviewCache cache;
const QImage large = filled(400, 200, QColor(0, 0, 255));
const LayerEffectsRenderer::Rendered whole = LayerEffectsRenderer::render(large, std::nullopt, outline(3));
// Within the default 1536 a side: the whole render.
const EffectsPreviewCache::Result now = cache.renderNow(large, std::nullopt, outline(3)).value();
QCOMPARE(now.image, whole.image);
QCOMPARE(now.inset, whole.inset);
// Sharing the budget with a thousand, as the worker reduces.
const ImageLayer layer = layerOf(large, outline(3));
std::vector<ImageLayer> many(999, layerOf(filled(2, 2, QColor(0, 0, 255)), outline(3)));
for (ImageLayer &each : many)
each.id = QUuid::createUuid();
many.push_back(layer);
cache.prepare(many);
const Landings landings;
ask(cache, layer, landings);
const EffectsPreviewCache::Result worker = landed(cache, layer, landings, 1);
const EffectsPreviewCache::Result reduced = cache.renderNow(large, std::nullopt, outline(3)).value();
QCOMPARE(reduced.image, worker.image);
QCOMPARE(reduced.inset, worker.inset);
// 129 a side: sides at 121 over the margined width.
const double factor = 121.0 / (400 + 2 * LayerEffectsRenderer::margin(outline(3)));
const LayerEffects scaledOutline = outline(3 * factor);
QCOMPARE(reduced.image.size(), LayerEffectsRenderer::render(filled(int(std::lround(400 * factor)), int(std::lround(200 * factor)), QColor(0, 0, 255)),
std::nullopt, scaledOutline).image.size());
}
void EffectsPreviewCacheTests::onlyTheNewestRequestLands()
+8
View File
@@ -41,6 +41,14 @@ inline bool reddish(QColor colour)
return colour.red() > 128 && colour.green() < 90 && colour.blue() < 90;
}
// The selection's highlight at 45% over what shows beneath.
inline bool highlightedOver(QColor under, QColor over, QColor highlight)
{
const auto mixed = [](int beneath, int ink, int shown) { return std::abs(shown - (beneath * 0.55 + ink * 0.45)) <= 2; };
return under != over && mixed(under.red(), highlight.red(), over.red()) && mixed(under.green(), highlight.green(), over.green())
&& mixed(under.blue(), highlight.blue(), over.blue());
}
inline bool greenish(QColor colour)
{
return colour.green() > 128 && colour.red() < 90 && colour.blue() < 90;
+13 -9
View File
@@ -58,12 +58,15 @@ void InlineTextDrawTests::theFrameAndHandlesSitOnTheBox()
QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width() / 4, 4))) != frame);
// No plus without overflow.
QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width(), size.height()))) == QColor(Qt::white));
// Text draws over the frame, a subview over its view.
// Tight leading lifts the H into the frame, still over.
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.leading = 30;
});
QVERIFY(reddish(shown.grab().pixelColor(shown.onScreen(QPointF(24, 0.25)))));
QCOMPARE(qAlpha(EditorSession::textImage(shown.session.textDraft().value().style).pixel(24, 0)), 255);
const QColor over = shown.grab().pixelColor(shown.onScreen(QPointF(24, 0.25)));
QVERIFY2(!reddish(over) && std::abs(over.red() - accent.red()) < 40 && std::abs(over.blue() - accent.blue()) < 40, qPrintable(over.name()));
QVERIFY(where(shown.grab(), reddish).isValid());
}
void InlineTextDrawTests::aTurnedFlippedLayerIsEditedInPlace()
@@ -151,17 +154,18 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
QTRY_VERIFY(shown.canvas->hasFocus());
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("iiii"));
const QImage plain = shown.grab();
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
const auto mixed = [&](QPoint at, QColor highlight) { return highlightedOver(plain.pixelColor(at), shown.grab().pixelColor(at), highlight); };
const auto highlighted = [&](QPoint at) { return mixed(at, shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); };
// Above the letters, the line's height is highlighted.
const QPoint above = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(above), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(above));
const double line = shown.session.textDraft().value().style.lineHeight();
const QPoint below = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + line - 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(below), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + line - 3)).toPoint()));
// To the last selected letter's edge.
const double selected = QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii"));
const QPoint last = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + selected - 3, LayerTextStyle::padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(last), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QVERIFY(highlighted(shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + selected - 3, LayerTextStyle::padding + 3)).toPoint()));
// No caret while something is selected.
const QPointF mapped = shown.editor().textTransform().map(
QPointF(LayerTextStyle::padding + QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii")) + 0.25,
@@ -170,7 +174,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
QVERIFY(!reddish(shown.grab().pixelColor(end)));
QTest::keyClick(shown.canvas, Qt::Key_End);
QVERIFY(reddish(shown.grab().pixelColor(end)));
QVERIFY(shown.grab().pixelColor(above) != shown.canvas->palette().color(QPalette::Active, QPalette::Highlight));
QCOMPARE(shown.grab().pixelColor(above), plain.pixelColor(above));
// Unfocused, a selection shows in the inactive colour.
QPalette palette = shown.canvas->palette();
palette.setColor(QPalette::Inactive, QPalette::Highlight, QColor(40, 200, 40));
@@ -180,7 +184,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
field->show();
field->setFocus();
QTRY_VERIFY(!shown.canvas->hasFocus());
QCOMPARE(shown.grab().pixelColor(above), QColor(40, 200, 40));
QVERIFY(mixed(above, QColor(40, 200, 40)));
}
void InlineTextDrawTests::textPastTheBoxShowsAPlus()
+16 -3
View File
@@ -22,6 +22,18 @@ void InlineTextPreeditTests::markedTextIsUnderlined()
const QRect band = shown.editor().textTransform().mapRect(
QRectF(LayerTextStyle::padding, baseline + 1, metrics.horizontalAdvance(QStringLiteral("nn")) - 4, metrics.descent() - 2)).toAlignedRect();
QVERIFY(where(shown.grab().copy(band), reddish).isValid());
// The input method's own underline, uncoloured, takes the text's.
QTextCharFormat underlined;
underlined.setFontUnderline(true);
QInputMethodEvent styled(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, underlined)});
QApplication::sendEvent(shown.canvas, &styled);
QVERIFY(where(shown.grab().copy(band), reddish).isValid());
// Its own colour stands.
underlined.setUnderlineColor(Qt::blue);
QInputMethodEvent blue(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, underlined)});
QApplication::sendEvent(shown.canvas, &blue);
QVERIFY(!where(shown.grab().copy(band), reddish).isValid());
QVERIFY(where(shown.grab().copy(band), [](QColor colour) { return colour.blue() > 128 && colour.red() < 90 && colour.green() < 90; }).isValid());
// A format that is none keeps the underline, as Qt's.
QInputMethodEvent formless(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, QVariant())});
QApplication::sendEvent(shown.canvas, &formless);
@@ -87,12 +99,13 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
shown.session.cancelText();
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("xx"));
const QImage plain = shown.grab();
QInputMethodEvent selecting(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 2, QVariant())});
QApplication::sendEvent(shown.canvas, &selecting);
QCOMPARE(shown.session.textDraft().value().style.content, QString("xxnn"));
const QPalette::ColorGroup group = shown.canvas->hasFocus() ? QPalette::Active : QPalette::Inactive;
const QPoint first = shown.editor().textTransform().map(QPointF(padding + 3, padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(highlightedOver(plain.pixelColor(first), shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight)));
// Past the preedit, it sits after the preedit's letters.
shown.session.cancelText();
beginTextAt(shown.session, QPointF(20, 30));
@@ -102,8 +115,8 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
QCOMPARE(shown.session.textDraft().value().style.content, QString("nnxx"));
const double nn = metrics.horizontalAdvance(QStringLiteral("nn"));
const QPoint x = shown.editor().textTransform().map(QPointF(padding + nn + 3, padding + 3)).toPoint();
QCOMPARE(shown.grab().pixelColor(x), shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(shown.grab().pixelColor(first) != shown.canvas->palette().color(group, QPalette::Highlight));
QVERIFY(highlightedOver(plain.pixelColor(x), shown.grab().pixelColor(x), shown.canvas->palette().color(group, QPalette::Highlight)));
QCOMPARE(shown.grab().pixelColor(first), plain.pixelColor(first));
}
QTEST_MAIN(InlineTextPreeditTests)
+31
View File
@@ -96,6 +96,7 @@ private slots:
void aSmallerMaskScalesSmoothlyAsTheRenderersDoes();
void aSurfaceHoldsEightyMillionPixelsAtMost();
void aSurfaceFitsTheStrokeItWasMadeFor();
void aMaskStrokeRedoesWhereItsTilesMap();
};
void LayerEffectsSurfaceTests::theFirstUpdateDrawsWhatTheRendererDraws()
@@ -192,5 +193,35 @@ void LayerEffectsSurfaceTests::aSurfaceFitsTheStrokeItWasMadeFor()
QVERIFY(!surface->matches(id, everyKind(), QSizeF(40, 30), QRectF(6, 5, 20, 10)));
}
void LayerEffectsSurfaceTests::aMaskStrokeRedoesWhereItsTilesMap()
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = 3, .red = 1, .green = 1, .blue = 0, .opacity = 1};
const ImportedImage base(solid(40, 40, qRgba(0, 0, 255, 255)), QImage(), QStringLiteral("Blue"));
// The mask as the stroke leaves it, over a region.
auto mask = std::make_shared<QImage>(BrushRaster::context(40, 40, true));
mask->fill(255);
const auto coverage = [mask](const QRectF &region) {
QImage live = BrushRaster::context(int(region.width()), int(region.height()), true);
QPainter painter(&live);
BrushRaster::draw(*mask, QRectF(-region.topLeft(), QSizeF(40, 40)), painter);
return live;
};
// A finer grid's tile, landing at 10 to 12.56.
const QTransform toGrid = QTransform::fromScale(0.01, 0.01);
const QRectF tile(1000, 1000, 256, 256);
const std::unique_ptr<LayerEffectsSurface> surface = LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(40, 40), QRectF(0, 0, 40, 40));
surface->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{BrushPatch{tile, solid(4, 4, 0)}}, toGrid, coverage});
// A hole under the tile, its pixels anew: redone there.
QPainter hole(mask.get());
hole.fillRect(QRectF(10, 10, 3, 3), Qt::black);
hole.end();
surface->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{BrushPatch{tile, solid(4, 4, 0)}}, toGrid, coverage});
const std::unique_ptr<LayerEffectsSurface> fresh = LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(40, 40), QRectF(0, 0, 40, 40));
fresh->update(base, {}, std::nullopt, LayerEffectsSurface::MaskStroke{{}, toGrid, coverage});
QCOMPARE(surface->image().value(), fresh->image().value());
QVERIFY(surface->image().value() != LayerEffectsRenderer::render(base.image(), std::nullopt, effects).image);
}
QTEST_GUILESS_MAIN(LayerEffectsSurfaceTests)
#include "LayerEffectsSurfaceTests.moc"
+159
View File
@@ -0,0 +1,159 @@
#include "EffectsCanvasFixtures.h"
// Swift 1.2.11 (b683acb): a painted mask keeps effects on.
namespace {
bool nearly(QColor colour, QColor expected)
{
return std::abs(colour.red() - expected.red()) <= 40 && std::abs(colour.green() - expected.green()) <= 40
&& std::abs(colour.blue() - expected.blue()) <= 40;
}
// Black down the mask, eight wide; a later pass cuts.
void paintHole(Scene &scene)
{
EditorSession &session = scene.session;
session.selectLayerTarget(session.activeLayerID().value(), true);
session.selectTool(NavigationTool::brush);
session.setMaskPaintWhite(false);
BrushSettings settings = session.brushSettings();
settings.diameter = 8;
settings.hardness = 1;
session.setBrushSettings(settings);
session.beginBrush(QPointF(30, 2));
session.continueBrush(QPointF(30, 6));
scene.shot();
session.continueBrush(QPointF(30, 35));
}
}
class MaskEffectsCanvasTests : public QObject {
Q_OBJECT
private slots:
void aPaintedMaskRedoesTheEffects();
void aPlacedMaskRedoesThemInTheLayersGrid();
void untouchedTilesKeepTheOldMask();
void aFinerPlacedMaskMapsIntoTheLayersGrid();
};
void MaskEffectsCanvasTests::aPaintedMaskRedoesTheEffects()
{
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
scene.landed();
QVERIFY(nearly(scene.at(QPoint(27, 20)), QColor(0, 0, 255)));
paintHole(scene);
// Mid-stroke the outline follows the hole: its middle is clear.
QVERIFY2(nearly(scene.at(QPoint(27, 20)), yellow), qPrintable(scene.at(QPoint(27, 20)).name()));
QVERIFY2(nearly(scene.at(QPoint(33, 20)), yellow), qPrintable(scene.at(QPoint(33, 20)).name()));
QCOMPARE(scene.at(QPoint(30, 20)), QColor(Qt::white));
QVERIFY(nearly(scene.at(QPoint(25, 20)), QColor(0, 0, 255)));
// Committed, the surface stands in until new effects land.
QVERIFY(scene.session.finishBrushImmediately());
scene.canvas.synchronizeDisplay();
QVERIFY(scene.session.effectsPreviews.rendered(scene.square).value().placement);
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
QTRY_VERIFY(!scene.session.effectsPreviews.rendered(scene.square).value().placement);
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
}
void MaskEffectsCanvasTests::aPlacedMaskRedoesThemInTheLayersGrid()
{
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
// A white mask in the layer's grid, placed on it.
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &square = record(snapshot, scene.square);
QImage white = BrushRaster::context(10, 10, true);
white.fill(255);
snapshot.masks.insert_or_assign(scene.square, ImportedImage(white, white, QStringLiteral("Mask")));
square.maskPlacement = square.transform;
square.maskLinked = false;
});
scene.session.selectLayer(scene.square);
QVERIFY(layerWith(scene.session, scene.square).mask.value().placement);
scene.landed();
paintHole(scene);
QVERIFY2(nearly(scene.at(QPoint(27, 20)), yellow), qPrintable(scene.at(QPoint(27, 20)).name()));
QVERIFY2(nearly(scene.at(QPoint(33, 20)), yellow), qPrintable(scene.at(QPoint(33, 20)).name()));
QCOMPARE(scene.at(QPoint(30, 20)), QColor(Qt::white));
QVERIFY(scene.session.finishBrushImmediately());
scene.canvas.synchronizeDisplay();
QVERIFY(nearly(scene.at(QPoint(27, 20)), yellow));
}
void MaskEffectsCanvasTests::untouchedTilesKeepTheOldMask()
{
// A layer wider than a tile, painted at one end.
EditorSession session;
CanvasView canvas{session};
session.createDocument(700, 40);
canvas.resize(700, 40);
session.viewport.resize(QSizeF(700, 40), 1, QSizeF(700, 40));
session.zoom(1);
const QImage blue = solid(600, 10, qRgba(0, 0, 255, 255));
session.insert(ImportedImage(blue, blue, QStringLiteral("Wide")), QPointF(350, 20));
session.setEffects(outline(2));
session.addLayerMask(true);
canvas.grab();
session.selectLayerTarget(session.activeLayerID().value(), true);
session.selectTool(NavigationTool::brush);
session.setMaskPaintWhite(false);
session.beginBrush(QPointF(100, 20));
const QImage shot = canvas.grab().toImage();
const QPointF far = session.viewport.viewPoint(QPointF(600.5, 20.5), QSizeF(700, 40));
QCOMPARE(shot.pixelColor(far.toPoint()), QColor(0, 0, 255));
}
void MaskEffectsCanvasTests::aFinerPlacedMaskMapsIntoTheLayersGrid()
{
// The committed mask's landed effects are the oracle.
Scene scene;
scene.session.setEffects(outline(2));
scene.session.addLayerMask(true);
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &square = record(snapshot, scene.square);
QImage white = BrushRaster::context(600, 600, true);
white.fill(255);
snapshot.masks.insert_or_assign(scene.square, ImportedImage(white, white, QStringLiteral("Mask")));
square.maskPlacement = square.transform;
square.maskLinked = false;
});
scene.session.selectLayer(scene.square);
scene.landed();
scene.session.selectLayerTarget(scene.square, true);
scene.session.selectTool(NavigationTool::brush);
scene.session.setMaskPaintWhite(false);
BrushSettings settings = scene.session.brushSettings();
settings.diameter = 1;
settings.hardness = 1;
scene.session.setBrushSettings(settings);
scene.session.beginBrush(QPointF(32, 16));
scene.shot();
scene.session.continueBrush(QPointF(32, 24));
const QImage painting = scene.shot();
// The oracle: a whole render through the stroke's mask.
const ImageLayer square = layerWith(scene.session, scene.square);
const QImage preview = scene.session.brushStroke()->placedMaskPreview(square.mask.value().placement.value()).value();
const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(square.asset.value().image(), preview, outline(2));
int largest = 0;
for (int y = 10; y < 30; ++y) {
for (int x = 20; x < 40; ++x) {
const QPoint at(x - 25 + int(expected.inset), y - 15 + int(expected.inset));
if (!expected.image.rect().contains(at))
continue;
const QColor over = expected.image.pixelColor(at);
const QColor shown = painting.pixelColor(scene.shown(QPointF(x + 0.5, y + 0.5)));
// Over white, premultiplied: the channel plus what shows through.
const auto on = [&](int channel) { return channel + 255 - over.alpha(); };
largest = std::max({largest, std::abs(shown.red() - on(qRound(over.redF() * over.alphaF() * 255))),
std::abs(shown.green() - on(qRound(over.greenF() * over.alphaF() * 255))),
std::abs(shown.blue() - on(qRound(over.blueF() * over.alphaF() * 255)))});
}
}
QVERIFY2(largest <= 2, qPrintable(QString::number(largest)));
}
QTEST_MAIN(MaskEffectsCanvasTests)
#include "MaskEffectsCanvasTests.moc"
+2
View File
@@ -49,6 +49,7 @@ compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(TypedTextCanvasTests)
compositor_test(LiveTextDoubleClickTests)
compositor_test(SVGImportTests)
compositor_test(TextLayoutTests)
@@ -198,6 +199,7 @@ compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(MaskEffectsCanvasTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)
+411
View File
@@ -0,0 +1,411 @@
#include "InlineTextDrawFixtures.h"
#include "Document/LayerEffects+Renderer.h"
// Swift 1.2.11: typed text drawn as its pixels.
namespace {
// Only the box's inside, where frame and handles never draw.
int largestChange(const QImage &before, const QImage &after, QRect inside)
{
int largest = 0;
inside &= before.rect();
for (int y = inside.top(); y <= inside.bottom(); ++y) {
for (int x = inside.left(); x <= inside.right(); ++x) {
const QRgb a = before.pixel(x, y), b = after.pixel(x, y);
largest = std::max({largest, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))});
}
}
return largest;
}
LayerEffects yellowStroke()
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = 3, .red = 1, .green = 1, .blue = 0, .opacity = 1};
return effects;
}
// The caret draws in the text's colour: keys go elsewhere.
void unfocus(DrawnText &shown)
{
QCoreApplication::processEvents();
shown.canvas->clearFocus();
if (!QTest::qWaitFor([&] { return !shown.canvas->hasFocus(); }))
throw std::runtime_error("the canvas kept the keys");
}
// Debug lines on the rendering category, counted.
int remakes = 0;
QtMessageHandler passOn = nullptr;
void countRemakes(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (message == QLatin1String("typed text remade"))
++remakes;
passOn(type, context, message);
}
// The union of the regions a widget paints.
struct PaintedRegion : QObject {
QRegion region;
bool eventFilter(QObject *, QEvent *event) override
{
if (event->type() == QEvent::Paint)
region |= static_cast<QPaintEvent *>(event)->region();
return false;
}
};
bool yellowish(QColor colour)
{
return colour.red() > 200 && colour.green() > 200 && colour.blue() < 60;
}
}
class TypedTextCanvasTests : public QObject {
Q_OBJECT
private slots:
void textLooksTheSameWhileEditingAndOnceCommitted_data();
void textLooksTheSameWhileEditingAndOnceCommitted();
void newTextSitsAboveTheActiveLayer();
void editedTextKeepsItsOpacityAndEffects();
void typedTextFollowsEachChange();
void aMaskHidesTypedTextsEffects();
void cancelledTextHandsOnNothing();
void typingRepaintsTheWholeView();
void aHandleThatOnlyMovesRepaintsTheWholeView();
void typedTextBlendsAsItsLayer();
void typedTextIsRemadeOnlyWhenItChanges();
void aMovedBoxRedoesItsTypedEffects();
};
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted_data()
{
QTest::addColumn<double>("zoom");
QTest::newRow("smooth") << 1.5;
QTest::newRow("hard") << 4.0;
}
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
{
QFETCH(double, zoom);
DrawnText shown;
shown.session.zoom(zoom);
shown.canvas->synchronizeDisplay();
const QImage blank = shown.grab();
// The box's middle near the view's; it grows by four.
shown.session.beginText(QPointF(163, 205), true);
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("Sharp");
style.fontSize = 24;
});
// Unfocused, no caret draws; the frame stays at the edge.
unfocus(shown);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
const QImage editing = shown.grab();
QVERIFY(largestChange(blank, editing, inside) > 100);
QVERIFY(shown.session.finishText());
QVERIFY(shown.session.activeLayer().value().liveText());
const QImage committed = shown.grab();
QVERIFY2(largestChange(editing, committed, inside) <= 2, qPrintable(QString::number(largestChange(editing, committed, inside))));
}
void TypedTextCanvasTests::newTextSitsAboveTheActiveLayer()
{
DrawnText shown;
// A drawn active layer, a green dot, under a cover.
QImage green = BrushRaster::context(2, 2, false);
green.fill(Qt::green);
shown.session.addPixelLayer(green, QPointF(300, 250), QStringLiteral("Dot"), QStringLiteral("Add"));
const QUuid below = shown.session.activeLayerID().value();
QImage white = BrushRaster::context(400, 300, false);
white.fill(Qt::white);
shown.session.addPixelLayer(white, QPointF(0, 0), QStringLiteral("Cover"), QStringLiteral("Add"));
const QUuid cover = shown.session.activeLayerID().value();
shown.session.selectLayer(below);
shown.session.selectTool(NavigationTool::type);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Hidden"); });
unfocus(shown);
QVERIFY(!where(shown.grab(), reddish).isValid());
shown.session.cancelText();
// Above the cover it shows, as its layer will.
shown.session.selectLayer(cover);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Shown"); });
unfocus(shown);
QVERIFY(where(shown.grab(), reddish).isValid());
shown.session.cancelText();
// A hidden active layer draws nothing: text goes on top.
shown.session.toggleLayerVisibility(below);
shown.session.selectLayer(below);
beginTextAt(shown.session, QPointF(40, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Top"); });
unfocus(shown);
QVERIFY(where(shown.grab(), reddish).isValid());
}
void TypedTextCanvasTests::editedTextKeepsItsOpacityAndEffects()
{
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 40), QStringLiteral("HH"));
shown.session.beginOpacityEdit();
shown.session.setLayerOpacity(0.5);
shown.session.finishOpacityEdit();
shown.session.editActiveText();
unfocus(shown);
// Half opacity: half red over the checkerboard, no full red.
const QRect ink = where(shown.grab(), [](QColor colour) { return colour.red() > 150 && colour.red() < 190 && colour.green() < 60; });
QVERIFY(ink.isValid());
QVERIFY(!where(shown.grab(), [](QColor colour) { return colour.red() > 240 && colour.green() < 60; }).isValid());
shown.session.cancelText();
shown.session.beginOpacityEdit();
shown.session.setLayerOpacity(1);
shown.session.finishOpacityEdit();
// Effects stay on while typing, redone at once.
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHH"); });
QVERIFY(where(shown.grab(), yellowish).isValid());
// Committed, the typed effects stand in: nothing blinks.
QVERIFY(shown.session.finishText());
shown.grab();
const EffectsPreviewCache::Result seeded = shown.session.effectsPreviews.rendered(id).value();
QVERIFY(seeded.placement);
QVERIFY(where(shown.grab(), yellowish).isValid());
// Handed on once: drawing again seeds nothing more.
QTRY_VERIFY((shown.grab(), !shown.session.effectsPreviews.rendered(id).value().placement));
// Cancelled, typed effects the layer never took hand on nothing.
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHHH"); });
shown.grab();
shown.session.cancelText();
shown.grab();
QVERIFY(!shown.session.effectsPreviews.rendered(id).value().placement);
}
void TypedTextCanvasTests::typedTextFollowsEachChange()
{
DrawnText shown;
shown.text(QPointF(40, 40), QStringLiteral("H"));
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
unfocus(shown);
const auto count = [&](bool (*test)(QColor)) {
const QImage image = shown.grab();
int found = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = 0; x < image.width(); ++x)
found += test(image.pixelColor(x, y));
return found;
};
const int ink = count(reddish), rim = count(yellowish);
QVERIFY(ink > 0 && rim > 0);
// More letters: more pixels and more effects, redrawn.
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHH"); });
QVERIFY(count(reddish) > ink * 2);
QVERIFY(count(yellowish) > rim * 2);
}
void TypedTextCanvasTests::aMaskHidesTypedTextsEffects()
{
// A black mask hides the whole text, linked.
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 150), QStringLiteral("HHHH"));
shown.session.setEffects(yellowStroke());
shown.session.addLayerMask(false);
shown.session.editActiveText();
unfocus(shown);
QVERIFY(!where(shown.grab(), reddish).isValid());
QVERIFY(!where(shown.grab(), yellowish).isValid());
shown.session.cancelText();
// A frame between, as the app always draws one.
shown.grab();
// Placed over the left half, its black middle hides there.
LayerTransform box;
rewrite(shown.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &text = record(snapshot, id);
box = text.transform;
QImage ring = BrushRaster::context(30, 30, true);
ring.fill(255);
QPainter hole(&ring);
hole.fillRect(QRect(10, 10, 10, 10), Qt::black);
hole.end();
snapshot.masks.insert_or_assign(id, ImportedImage(ring, ring, QStringLiteral("Mask")));
text.maskPlacement = LayerTransform{.origin = box.origin, .size = QSizeF(box.size.width() / 2, box.size.height())};
text.maskLinked = false;
});
shown.session.selectLayer(id);
shown.session.editActiveText();
unfocus(shown);
const QImage image = shown.grab();
const QSizeF size = shown.documentSize();
const auto view = [&](double x, double y) {
return shown.session.viewport.viewPoint(box.origin + QPointF(box.size.width() * x, box.size.height() * y), size).toPoint();
};
// Stretched over the whole text, that band would show.
QVERIFY(!where(image.copy(QRect(view(1 / 6.0 + 0.02, 0.4), view(1 / 3.0 - 0.02, 0.6))), reddish).isValid());
QVERIFY(where(image.copy(QRect(view(0.5, 0), view(1, 1))), reddish).isValid());
}
void TypedTextCanvasTests::cancelledTextHandsOnNothing()
{
// Effects never rendered for these pixels: a seed would show.
DrawnText shown;
const QUuid id = shown.text(QPointF(40, 40), QStringLiteral("HH"));
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HHHH"); });
shown.grab();
shown.session.cancelText();
shown.grab();
const std::optional<EffectsPreviewCache::Result> shownEffects = shown.session.effectsPreviews.rendered(id);
QVERIFY(!shownEffects || !shownEffects.value().placement);
}
void TypedTextCanvasTests::typingRepaintsTheWholeView()
{
// Box text's style alone changes; its effects need the view.
DrawnText shown;
shown.session.beginText(QRectF(40, 100, 100, 60));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.fontSize = 20;
});
QVERIFY(shown.session.finishText());
shown.session.setEffects(yellowStroke());
shown.session.editActiveText();
// Any preview still to land has landed and painted.
QTest::qWait(400);
PaintedRegion painted;
shown.canvas->installEventFilter(&painted);
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HH"); });
QTRY_VERIFY(!painted.region.isEmpty());
QCOMPARE(painted.region.boundingRect(), shown.canvas->rect());
}
void TypedTextCanvasTests::aHandleThatOnlyMovesRepaintsTheWholeView()
{
// At 4x, one point's drag moves the box, same size.
DrawnText shown;
shown.session.beginText(QRectF(185, 135, 50, 40));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("H");
style.fontSize = 20;
});
shown.session.zoom(4);
shown.canvas->synchronizeDisplay();
const LayerTextStyle before = shown.session.textDraft().value().style;
const QPointF left = shown.editor().boxTransform().map(QPointF(0, shown.editor().logicalSize().height() / 2));
shown.press(left);
QTest::qWait(50);
PaintedRegion painted;
shown.canvas->installEventFilter(&painted);
shown.move(left + QPointF(1, 0));
QCOMPARE(shown.session.textDraft().value().style, before);
QTRY_VERIFY(!painted.region.isEmpty());
QCOMPARE(painted.region.boundingRect(), shown.canvas->rect());
shown.release(left + QPointF(1, 0));
}
void TypedTextCanvasTests::typedTextBlendsAsItsLayer()
{
// Red multiplied over green shows black, typed as committed.
for (const bool effects : {false, true}) {
DrawnText shown;
QImage green = BrushRaster::context(400, 300, false);
green.fill(Qt::green);
shown.session.addPixelLayer(green, QPointF(0, 0), QStringLiteral("Green"), QStringLiteral("Add"));
shown.text(QPointF(40, 150), QStringLiteral("HH"));
shown.session.setLayerBlendMode(LayerBlendMode::multiply);
if (effects)
shown.session.setEffects(yellowStroke());
QTRY_VERIFY(!effects || (shown.grab(), shown.session.effectsPreviews.rendered(shown.session.activeLayerID().value())));
const QImage committed = shown.grab();
shown.session.editActiveText();
unfocus(shown);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
const QImage typed = shown.grab();
QVERIFY(!where(typed.copy(inside), reddish).isValid());
QVERIFY2(largestChange(committed, typed, inside) <= 2, qPrintable(QString::number(largestChange(committed, typed, inside))));
}
}
void TypedTextCanvasTests::typedTextIsRemadeOnlyWhenItChanges()
{
QLoggingCategory::setFilterRules(QStringLiteral("omaphoto.rendering.debug=true"));
passOn = qInstallMessageHandler(countRemakes);
DrawnText shown;
beginTextAt(shown.session, QPointF(40, 150));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("H"); });
remakes = 0;
shown.grab();
shown.grab();
QCOMPARE(remakes, 1);
shown.session.changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("HH"); });
shown.grab();
shown.grab();
QCOMPARE(remakes, 2);
// Ended, the image goes: the same style is made again.
const LayerTextStyle typed = shown.session.textDraft().value().style;
shown.session.cancelText();
shown.grab();
beginTextAt(shown.session, QPointF(40, 150));
shown.session.changeTextStyle([&](LayerTextStyle &style) { style = typed; });
shown.grab();
QCOMPARE(remakes, 3);
qInstallMessageHandler(passOn);
QLoggingCategory::setFilterRules(QString());
}
void TypedTextCanvasTests::aMovedBoxRedoesItsTypedEffects()
{
// A placed mask's band over box text with effects.
DrawnText shown;
shown.session.beginText(QRectF(185, 135, 50, 60));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.content = QStringLiteral("HH");
style.fontSize = 20;
});
QVERIFY(shown.session.finishText());
const QUuid id = shown.session.activeLayerID().value();
shown.session.setEffects(yellowStroke());
shown.session.addLayerMask(true);
rewrite(shown.session, [&](ProjectSnapshot &snapshot) {
ProjectLayerRecord &text = record(snapshot, id);
QImage band = BrushRaster::context(50, 40, true);
band.fill(255);
QPainter painter(&band);
painter.fillRect(QRect(20, 0, 10, 40), Qt::black);
painter.end();
snapshot.masks.insert_or_assign(id, ImportedImage(band, band, QStringLiteral("Mask")));
text.maskPlacement = LayerTransform{.origin = text.transform.origin, .size = QSizeF(40, text.transform.size.height())};
text.maskLinked = false;
});
shown.session.selectLayer(id);
shown.session.editActiveText();
unfocus(shown);
shown.session.zoom(4);
shown.canvas->synchronizeDisplay();
shown.grab();
// A fresh canvas renders the effects anew; they agree.
const LayerTextStyle before = shown.session.textDraft().value().style;
const LayerTransform moved = shown.editor().shownTransform();
const QPointF left = shown.editor().boxTransform().map(QPointF(0, shown.editor().logicalSize().height() / 2));
shown.press(left);
shown.move(left + QPointF(1, 0));
shown.release(left + QPointF(1, 0));
QCOMPARE(shown.session.textDraft().value().style, before);
QVERIFY(shown.editor().shownTransform().origin != moved.origin);
QVERIFY(layerWith(shown.session, id).mask.value().placement);
const QImage kept = shown.grab();
QVERIFY(where(kept, reddish).isValid() && where(kept, yellowish).isValid());
// A fresh canvas renders the effects anew: they must agree.
CanvasView fresh(shown.session);
fresh.resize(shown.canvas->size());
fresh.synchronizeDisplay();
const QImage anew = fresh.grab().toImage().convertToFormat(QImage::Format_RGB32);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);
QVERIFY2(largestChange(kept, anew, inside) <= 2, qPrintable(QString::number(largestChange(kept, anew, inside))));
}
QTEST_MAIN(TypedTextCanvasTests)
#include "TypedTextCanvasTests.moc"