Colour only the selected letters of text being edited; project format version 10 (12.14d1)
This commit is contained in:
1 parent
2fa8d9921d
commit
27a4fe8428
32 files changed
+1398
-82
No files matched your search
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
|
||||
const QString path = root.filePath("Dimmed.comp");
|
||||
ProjectStore::save(session.projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
QCOMPARE(loaded.manifest.version, qint64(10));
|
||||
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
|
||||
if (record.isGroup == true)
|
||||
QCOMPARE(record.opacity, std::optional(0.5));
|
||||
|
||||
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
|
||||
session.addBlankLayer();
|
||||
const QUuid child = session.activeLayerID().value();
|
||||
const ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(9));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(10));
|
||||
const auto record = [](const ProjectSnapshot &from, QUuid id) {
|
||||
for (const ProjectLayerRecord &layer : from.manifest.layers) {
|
||||
if (layer.id == id)
|
||||
|
||||
@@ -259,10 +259,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
|
||||
QCOMPARE(invalid.version, std::nullopt);
|
||||
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
|
||||
|
||||
const ProjectError version = ProjectError::unsupportedVersion(10);
|
||||
const ProjectError version = ProjectError::unsupportedVersion(11);
|
||||
QCOMPARE(version.kind, ProjectError::Kind::version);
|
||||
QCOMPARE(version.version, std::optional<int>(10));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 10. This app supports versions 1–9."));
|
||||
QCOMPARE(version.version, std::optional<int>(11));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 11. This app supports versions 1–10."));
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
|
||||
|
||||
const ProjectError missingImage(ProjectError::Kind::missingImage);
|
||||
|
||||
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
|
||||
session.addGuide(first);
|
||||
session.addGuide(second);
|
||||
ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(9));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(10));
|
||||
QCOMPARE(snapshot.manifest.guides.value(), (std::vector<CanvasGuide>{first, second}));
|
||||
// Swift's keys: an array of id, axis and position.
|
||||
const QByteArray json = snapshot.manifest.encoded();
|
||||
|
||||
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
|
||||
const QString path = folder.filePath("Appearance.comp");
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
QCOMPARE(loaded.manifest.version, qint64(10));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
|
||||
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
|
||||
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
|
||||
|
||||
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
|
||||
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
QCOMPARE(loaded.manifest.version, qint64(10));
|
||||
session->installProject(loaded, path);
|
||||
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
|
||||
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
|
||||
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
|
||||
const ProjectSnapshot snapshot = session->projectSnapshot().value();
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QVERIFY(loaded.manifest.version == 9 && loaded.masks.count(folder) == 1);
|
||||
QVERIFY(loaded.manifest.version == 10 && loaded.masks.count(folder) == 1);
|
||||
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(alphas(ImageExporter::render(resized).image),
|
||||
|
||||
@@ -158,7 +158,7 @@ void LiveTextDoubleClickTests::theCaretWaitsAfterTheText()
|
||||
shown.session.cancelText();
|
||||
shown.session.editActiveText();
|
||||
shown.session.cancelText();
|
||||
TextDraft fresh{.documentID = shown.session.document().value().id, .layerID = std::nullopt, .origin = QPointF(20, 200), .style = style};
|
||||
TextDraft fresh{.documentID = shown.session.document().value().id, .layerID = std::nullopt, .origin = QPointF(20, 200), .style = style, .selection = TextSpan()};
|
||||
fresh.style.content = QStringLiteral("New");
|
||||
shown.session.setTextDraft(fresh);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
@@ -313,7 +313,7 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
for (int version = 1; version <= 9; ++version)
|
||||
QTest::addRow("plain at version %d", version) << "" << version << fine;
|
||||
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 10") << "" << 10 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 11") << "" << 11 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("another format") << "format" << 7 << invalid;
|
||||
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
|
||||
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
|
||||
@@ -366,6 +366,9 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
QTest::newRow("an image under another name") << "imageName" << 7 << invalid;
|
||||
QTest::newRow("an image named in lower case") << "imageLower" << 7 << invalid;
|
||||
QTest::newRow("an active layer that is not there") << "active" << 7 << invalid;
|
||||
QTest::newRow("letters in colours before version 10") << "textRuns" << 9 << invalid;
|
||||
QTest::newRow("letters in colours at version 10") << "textRuns" << 10 << fine;
|
||||
QTest::newRow("one-colour text before version 10") << "text" << 9 << fine;
|
||||
}
|
||||
|
||||
void ProjectManifestTests::validationFollowsTheFormatsVersions()
|
||||
@@ -455,7 +458,13 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions()
|
||||
image.imageFile = "image.png";
|
||||
else if (change == "imageLower")
|
||||
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
|
||||
else if (change == "active")
|
||||
else if (change == "text" || change == "textRuns") {
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Ab");
|
||||
if (change == "textRuns")
|
||||
style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
|
||||
image.text = style;
|
||||
} else if (change == "active")
|
||||
manifest.activeLayerID = QUuid::createUuid();
|
||||
else
|
||||
QVERIFY(change.isEmpty());
|
||||
|
||||
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
|
||||
const ProjectSnapshot again = session.projectSnapshot().value();
|
||||
// The same manifest to the byte; pixels by identity.
|
||||
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
|
||||
QCOMPARE(again.manifest.version, qint64(9));
|
||||
QCOMPARE(again.manifest.version, qint64(10));
|
||||
QCOMPARE(int(again.images.size()), 2);
|
||||
QCOMPARE(int(again.masks.size()), 2);
|
||||
for (const auto &[id, image] : snapshot.images)
|
||||
|
||||
+12
-12
@@ -53,10 +53,10 @@ private slots:
|
||||
void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts()
|
||||
{
|
||||
QVERIFY(ProjectManifest::supports(ProjectManifest::current));
|
||||
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 9)));
|
||||
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 10)));
|
||||
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
|
||||
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
|
||||
QCOMPARE(QString(ProjectError::unsupportedVersion(10).what()), QString("This project uses format version 10. This app supports versions 1–9."));
|
||||
QCOMPARE(QString(ProjectError::unsupportedVersion(11).what()), QString("This project uses format version 11. This app supports versions 1–10."));
|
||||
}
|
||||
|
||||
void ProjectTests::projectRoundTripSurvivesPackageMove()
|
||||
@@ -201,14 +201,14 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
ProjectStore::save(snapshot, path);
|
||||
const QByteArray good = contents(metadata);
|
||||
// Each edit below replaces this text; it must be there.
|
||||
QVERIFY(good.contains("\"version\": 9"));
|
||||
QVERIFY(good.contains("\"version\": 10"));
|
||||
const auto loading = [&](const QByteArray &manifest) {
|
||||
overwrite(metadata, manifest);
|
||||
return projectError([&] { ProjectStore::load(path); });
|
||||
};
|
||||
// A version out of range is named whatever else fails.
|
||||
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
|
||||
for (const qint64 strange : {qint64(42), qint64(10), qint64(0)}) {
|
||||
for (const qint64 strange : {qint64(42), qint64(11), qint64(0)}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
|
||||
ProjectStore::load(path);
|
||||
@@ -223,12 +223,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
|
||||
// Another format is invalid whatever version it claims.
|
||||
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
// Swift's Int holds 64 bits, each one: no double between.
|
||||
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
|
||||
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": " + QByteArray::number(wild)));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": " + QByteArray::number(wild)));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a wild version opened");
|
||||
} catch (const ProjectError &error) {
|
||||
@@ -238,19 +238,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
}
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
// Declared: Qt rounds a token just past Int64's minimum.
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": -9223372036854775809"));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": -9223372036854775809"));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a version below every integer opened");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.version, std::optional(least));
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
|
||||
|
||||
@@ -169,6 +169,9 @@ compositor_test(QuitWhileEditingTextTests)
|
||||
compositor_test(OpenRecentTests)
|
||||
compositor_test(ColoredSliderTests)
|
||||
compositor_test(SheetTeardownTests)
|
||||
compositor_test(TextColorRunsTests)
|
||||
compositor_test(TextColorModelTests)
|
||||
compositor_test(TextColorEditorTests)
|
||||
compositor_test(RecentProjectsCoreTests)
|
||||
compositor_test(ProjectWorkspaceSignalTests)
|
||||
compositor_test(ProjectWorkspaceTests)
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
#include "InlineTextDrawFixtures.h"
|
||||
#include "Rendering/TextLayout.h"
|
||||
#include "UI/ColorPaletteControls.h"
|
||||
#include "UI/TypeControls.h"
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QLineEdit>
|
||||
#include <QInputMethodEvent>
|
||||
#include <QPainter>
|
||||
#include <QFontMetricsF>
|
||||
|
||||
// Swift 1.3.2 on the canvas: colours keep to their letters.
|
||||
namespace {
|
||||
const PaletteColor green{0, 1, 0};
|
||||
|
||||
std::optional<std::vector<LayerTextColorRun>> runs(const DrawnText &shown)
|
||||
{
|
||||
return shown.session.textDraft().value().style.colorRuns;
|
||||
}
|
||||
|
||||
LayerTextColorRun greenRun(qint64 location, qint64 length)
|
||||
{
|
||||
return {location, length, 0, 1, 0};
|
||||
}
|
||||
|
||||
// The caret's middle on screen, `advance` along the first line.
|
||||
QPoint caretAt(const DrawnText &shown, const QString &before)
|
||||
{
|
||||
const LayerTextStyle &style = shown.session.textDraft().value().style;
|
||||
const QRectF caret = shown.editor().textTransform().mapRect(
|
||||
QRectF(QPointF(LayerTextStyle::padding, LayerTextStyle::padding), QSizeF(1, style.lineHeight()))
|
||||
.translated(QFontMetricsF(TextLayout::font(style)).horizontalAdvance(before), 0));
|
||||
return caret.center().toPoint();
|
||||
}
|
||||
|
||||
// A caret a pixel wide may straddle two pixels.
|
||||
bool inked(const QImage &image, QPoint at, bool (*test)(QColor))
|
||||
{
|
||||
return test(image.pixelColor(at)) || test(image.pixelColor(at - QPoint(1, 0))) || test(image.pixelColor(at + QPoint(1, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
class TextColorEditorTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theSelectionReachesTheDraft();
|
||||
void coloursFollowTypingDeletingAndUndo();
|
||||
void pastingKeepsTheColoursAround();
|
||||
void aLetterTypedBetweenTwinsTakesTheOneBeforeIt();
|
||||
void theCaretTakesTheLetterBeforeIt();
|
||||
void theSwatchKeepsTheSelectionItPaints();
|
||||
void theLayerDrawsEachRunInItsColour();
|
||||
void theEditorDrawsNoGlyphsOfItsOwn();
|
||||
void twinsKeepTheirColoursOnEveryPath_data();
|
||||
void twinsKeepTheirColoursOnEveryPath();
|
||||
void aColouredTailOutlastsEveryStep_data();
|
||||
void aColouredTailOutlastsEveryStep();
|
||||
void aPreeditOverTheFirstLetterTakesItsColour();
|
||||
void atTheStartTheFirstLetterGivesTheColour();
|
||||
};
|
||||
|
||||
void TextColorEditorTests::theSelectionReachesTheDraft()
|
||||
{
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
|
||||
QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{4, 0}));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{2, 2}));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{0, 0}));
|
||||
// Select All reaches it too.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
|
||||
QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{0, 4}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::coloursFollowTypingDeletingAndUndo()
|
||||
{
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(1, 2)}));
|
||||
// After a green letter, typing is green; after red, red.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("x"));
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("abcxd"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(1, 3)}));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("y"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(2, 3)}));
|
||||
// A deletion takes its letters' colours away.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Backspace);
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("aycxd"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(2, 2)}));
|
||||
// Undo puts the letter back in the colour before it.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("aybcxd"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(3, 2)}));
|
||||
// Deleting every green letter leaves one colour.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_End);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left);
|
||||
for (int step = 0; step < 3; ++step)
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Delete);
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("ayd"));
|
||||
QVERIFY(!runs(shown));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::pastingKeepsTheColoursAround()
|
||||
{
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(3, 1)}));
|
||||
// Pasted at the start, before every run: runs move on.
|
||||
QGuiApplication::clipboard()->setText(QStringLiteral("123"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
shown.editor().paste();
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("123abcd"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(6, 1)}));
|
||||
// Pasted after the green letter, green.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_End);
|
||||
shown.editor().paste();
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(6, 4)}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::aLetterTypedBetweenTwinsTakesTheOneBeforeIt()
|
||||
{
|
||||
// Typed after a green a, before a red one: green.
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("aa"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 1)}));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
QCOMPARE(shown.editor().caretPosition(), 1);
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("a"));
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("aaa"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 2)}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::theCaretTakesTheLetterBeforeIt()
|
||||
{
|
||||
DrawnText shown;
|
||||
auto *field = new QLineEdit(&shown.window);
|
||||
field->setGeometry(0, 0, 10, 10);
|
||||
field->show();
|
||||
shown.canvas->setFocus();
|
||||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("ab"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
// After the green b: green; after the red a: red.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_End);
|
||||
QVERIFY(shown.editor().caretShown());
|
||||
const QPoint afterB = caretAt(shown, QStringLiteral("ab"));
|
||||
QVERIFY(inked(shown.grab(), afterB, greenish));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left);
|
||||
const QPoint afterA = caretAt(shown, QStringLiteral("a"));
|
||||
QTRY_VERIFY(shown.editor().caretShown());
|
||||
QVERIFY(inked(shown.grab(), afterA, reddish));
|
||||
QVERIFY(!inked(shown.grab(), afterA, greenish));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::theSwatchKeepsTheSelectionItPaints()
|
||||
{
|
||||
// Swift's 4503998: the Type bar's swatch leaves the letters selected.
|
||||
DrawnText shown;
|
||||
TypeControls bar(shown.session, &shown.window);
|
||||
bar.setGeometry(0, 0, 400, 30);
|
||||
bar.show();
|
||||
shown.canvas->setFocus();
|
||||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||||
beginTextAt(shown.session, QPointF(20, 60));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("AAAA BBBB"));
|
||||
for (int step = 0; step < 4; ++step)
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
auto &swatch = *bar.findChild<SwatchButton *>(QStringLiteral("typeColor"));
|
||||
QTest::mouseClick(&swatch, Qt::LeftButton);
|
||||
QVERIFY(shown.session.colorPicker().has_value());
|
||||
QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{5, 4}));
|
||||
QVERIFY(shown.editor().anchor() == 9 && shown.editor().caretPosition() == 5);
|
||||
PickerHSB hsb = shown.session.colorPicker().value().hsb;
|
||||
hsb.setRGB(green);
|
||||
shown.session.setColorPickerHSB(hsb);
|
||||
shown.session.closeColorPicker(true);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(5, 4)}));
|
||||
QCOMPARE(shown.session.textDraft().value().style.red, 1.0);
|
||||
}
|
||||
|
||||
void TextColorEditorTests::theLayerDrawsEachRunInItsColour()
|
||||
{
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("MMMM MMMM"));
|
||||
for (int step = 0; step < 4; ++step)
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QVERIFY(shown.session.finishText());
|
||||
const QImage image = shown.session.activeLayer().value().asset.value().image();
|
||||
// Red on the left, green on the right, no fringe.
|
||||
int reds = 0, greens = 0, dark = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const QColor colour = image.pixelColor(x, y);
|
||||
if (colour.alphaF() < 0.05f)
|
||||
continue;
|
||||
const double alpha = colour.alphaF();
|
||||
reds += colour.redF() > 0.8 * alpha && x < image.width() / 2;
|
||||
greens += colour.greenF() > 0.8 * alpha && x > image.width() / 2;
|
||||
// A glyph drawn twice leaves red under green edges.
|
||||
dark += colour.redF() > 0.05 && colour.greenF() > 0.05;
|
||||
}
|
||||
}
|
||||
QVERIFY2(reds > 100 && greens > 100 && dark == 0, qPrintable(QStringLiteral("%1 %2 %3").arg(reds).arg(greens).arg(dark)));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::theEditorDrawsNoGlyphsOfItsOwn()
|
||||
{
|
||||
// The runs belong to the layer: overlay glyphs stay clear.
|
||||
DrawnText shown;
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
shown.session.changeTextStyle([](LayerTextStyle &style) {
|
||||
style.content = QStringLiteral("MMMM");
|
||||
style.setColor(PaletteColor{0, 1, 0}, {0, 4});
|
||||
style.setColor(PaletteColor{1, 0, 0}, {0, 2});
|
||||
});
|
||||
QVERIFY(runs(shown).has_value());
|
||||
const auto overlay = [&shown] {
|
||||
QImage drawn(shown.canvas->size(), QImage::Format_ARGB32_Premultiplied);
|
||||
drawn.fill(Qt::transparent);
|
||||
QPainter painter(&drawn);
|
||||
shown.editor().draw(painter);
|
||||
painter.end();
|
||||
return drawn;
|
||||
};
|
||||
// Only the frame, handles and translucent highlight may show.
|
||||
const auto glyphs = [](const QImage &drawn) {
|
||||
int count = 0;
|
||||
for (int y = 0; y < drawn.height(); ++y)
|
||||
for (int x = 0; x < drawn.width(); ++x)
|
||||
count += drawn.pixelColor(x, y).alpha() > 128 && (greenish(drawn.pixelColor(x, y)) || reddish(drawn.pixelColor(x, y)));
|
||||
return count;
|
||||
};
|
||||
shown.canvas->clearFocus();
|
||||
QTRY_VERIFY(!shown.editor().caretShown());
|
||||
QCOMPARE(glyphs(overlay()), 0);
|
||||
// Selected, with and without the keys, as under the picker.
|
||||
shown.canvas->setFocus();
|
||||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||||
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
|
||||
for (const bool focused : {true, false}) {
|
||||
if (!focused) {
|
||||
shown.canvas->clearFocus();
|
||||
QTRY_VERIFY(!shown.canvas->hasFocus());
|
||||
}
|
||||
const QImage drawn = overlay();
|
||||
QCOMPARE(glyphs(drawn), 0);
|
||||
// The highlight at 45% over the letters' place.
|
||||
const QPoint middle = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 10, LayerTextStyle::padding + 20)).toPoint();
|
||||
QVERIFY2(std::abs(drawn.pixelColor(middle).alpha() - 115) <= 2, qPrintable(QString::number(drawn.pixelColor(middle).alpha())));
|
||||
}
|
||||
}
|
||||
|
||||
void TextColorEditorTests::twinsKeepTheirColoursOnEveryPath_data()
|
||||
{
|
||||
QTest::addColumn<QString>("path");
|
||||
QTest::addColumn<QString>("content");
|
||||
QTest::addColumn<qint64>("greens");
|
||||
for (const char *path : {"paste", "commit", "preedit"})
|
||||
QTest::newRow(path) << path << "aaa" << qint64(2);
|
||||
QTest::newRow("cut") << "cut" << "a" << qint64(0);
|
||||
QTest::newRow("undo") << "undo" << "aa" << qint64(1);
|
||||
QTest::newRow("redo") << "redo" << "aaa" << qint64(2);
|
||||
QTest::newRow("replaced by itself") << "same" << "aa" << qint64(2);
|
||||
// Around a preedit: it sits green between the twins.
|
||||
QTest::newRow("paste while composing") << "pasteComposing" << "aqxa" << qint64(3);
|
||||
QTest::newRow("commit over a preedit") << "commitOverPreedit" << "aya" << qint64(2);
|
||||
QTest::newRow("commit over a selection") << "commitOverSelection" << "zza" << qint64(2);
|
||||
QTest::newRow("undo of two typed") << "undoTwo" << "aa" << qint64(1);
|
||||
QTest::newRow("a preedit typed over, undone") << "typedOverPreedit" << "aa" << qint64(1);
|
||||
QTest::newRow("undo while composing") << "undoComposing" << "aa" << qint64(1);
|
||||
}
|
||||
|
||||
void TextColorEditorTests::twinsKeepTheirColoursOnEveryPath()
|
||||
{
|
||||
// A green a, then a red one, the caret between.
|
||||
QFETCH(QString, path);
|
||||
QFETCH(QString, content);
|
||||
QFETCH(qint64, greens);
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("aa"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
// A plain arrow collapses the selection to its own side.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
QCOMPARE(shown.editor().caretPosition(), 1);
|
||||
const auto input = [&](const QString &commit, const QString &preedit) {
|
||||
QInputMethodEvent event(preedit, {});
|
||||
event.setCommitString(commit);
|
||||
QApplication::sendEvent(shown.canvas, &event);
|
||||
};
|
||||
if (path == "paste") {
|
||||
QGuiApplication::clipboard()->setText(QStringLiteral("a"));
|
||||
shown.editor().paste();
|
||||
} else if (path == "commit") {
|
||||
input(QStringLiteral("a"), QString());
|
||||
} else if (path == "preedit") {
|
||||
input(QString(), QStringLiteral("a"));
|
||||
} else if (path == "cut") {
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.editor().cut();
|
||||
} else if (path == "undo" || path == "redo") {
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("a"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
if (path == "redo")
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
|
||||
} else if (path == "pasteComposing") {
|
||||
input(QString(), QStringLiteral("x"));
|
||||
QGuiApplication::clipboard()->setText(QStringLiteral("q"));
|
||||
shown.editor().paste();
|
||||
} else if (path == "commitOverPreedit") {
|
||||
input(QString(), QStringLiteral("x"));
|
||||
input(QStringLiteral("y"), QString());
|
||||
} else if (path == "commitOverSelection") {
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
input(QStringLiteral("zz"), QString());
|
||||
} else if (path == "undoTwo") {
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("bc"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
} else if (path == "typedOverPreedit") {
|
||||
input(QString(), QStringLiteral("x"));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("b"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
} else if (path == "undoComposing") {
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("b"));
|
||||
input(QString(), QStringLiteral("x"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
} else {
|
||||
// The red letter replaced by itself takes the green before.
|
||||
QTest::keyClick(shown.canvas, Qt::Key_End);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
input(QStringLiteral("a"), QString());
|
||||
}
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, content);
|
||||
if (greens == 0)
|
||||
QVERIFY(!runs(shown));
|
||||
else
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, greens)}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::aColouredTailOutlastsEveryStep_data()
|
||||
{
|
||||
QTest::addColumn<QString>("path");
|
||||
QTest::addColumn<QString>("content");
|
||||
QTest::newRow("typing undone and redone") << "redo" << "axybcd";
|
||||
QTest::newRow("deletions undone") << "undoDeletes" << "abcd";
|
||||
QTest::newRow("commit with a preedit") << "commitAndPreedit" << "axyzbcd";
|
||||
QTest::newRow("paste around a preedit") << "pasteComposing" << "aqqxbcd";
|
||||
}
|
||||
|
||||
void TextColorEditorTests::aColouredTailOutlastsEveryStep()
|
||||
{
|
||||
// setMarkedText replaces a selection whole: its first colour.
|
||||
QFETCH(QString, path);
|
||||
QFETCH(QString, content);
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right);
|
||||
const auto input = [&](const QString &commit, const QString &preedit) {
|
||||
QInputMethodEvent event(preedit, {});
|
||||
event.setCommitString(commit);
|
||||
QApplication::sendEvent(shown.canvas, &event);
|
||||
};
|
||||
if (path == "redo") {
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("xy"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
|
||||
} else if (path == "undoDeletes") {
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Delete);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Delete);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
} else if (path == "commitAndPreedit") {
|
||||
input(QStringLiteral("xy"), QStringLiteral("z"));
|
||||
} else {
|
||||
input(QString(), QStringLiteral("x"));
|
||||
QGuiApplication::clipboard()->setText(QStringLiteral("qq"));
|
||||
shown.editor().paste();
|
||||
}
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, content);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(content.size() - 1, 1)}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::aPreeditOverTheFirstLetterTakesItsColour()
|
||||
{
|
||||
// setMarkedText replaces the selection whole: its first letter's colour.
|
||||
DrawnText shown;
|
||||
shown.canvas->setFocus();
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("abc"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_End);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
QInputMethodEvent event(QStringLiteral("xy"), {});
|
||||
QApplication::sendEvent(shown.canvas, &event);
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("xybc"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 2), greenRun(3, 1)}));
|
||||
// Updated, its cursor moved, then committed: the colour carries on.
|
||||
QInputMethodEvent updated(QStringLiteral("xyz"), {});
|
||||
QApplication::sendEvent(shown.canvas, &updated);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 3), greenRun(4, 1)}));
|
||||
QInputMethodEvent moved(QStringLiteral("xyz"), {QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 0, 1)});
|
||||
QApplication::sendEvent(shown.canvas, &moved);
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 3), greenRun(4, 1)}));
|
||||
QInputMethodEvent committed;
|
||||
committed.setCommitString(QStringLiteral("q"));
|
||||
QApplication::sendEvent(shown.canvas, &committed);
|
||||
QCOMPARE(shown.session.textDraft().value().style.content, QString("qbc"));
|
||||
QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 1), greenRun(2, 1)}));
|
||||
}
|
||||
|
||||
void TextColorEditorTests::atTheStartTheFirstLetterGivesTheColour()
|
||||
{
|
||||
// No letter before the caret: the first one's colour.
|
||||
DrawnText shown;
|
||||
auto *field = new QLineEdit(&shown.window);
|
||||
field->setGeometry(0, 0, 10, 10);
|
||||
field->show();
|
||||
shown.canvas->setFocus();
|
||||
QTRY_VERIFY(shown.canvas->hasFocus());
|
||||
beginTextAt(shown.session, QPointF(20, 30));
|
||||
QTest::keyClicks(shown.canvas, QStringLiteral("ab"));
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
shown.session.setPaletteColor(green, false);
|
||||
QTest::keyClick(shown.canvas, Qt::Key_Home);
|
||||
QCOMPARE(shown.editor().caretPosition(), 0);
|
||||
QVERIFY(shown.session.typeColor() == green);
|
||||
QTRY_VERIFY(shown.editor().caretShown());
|
||||
QVERIFY(inked(shown.grab(), caretAt(shown, QString()), greenish));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextColorEditorTests)
|
||||
#include "TextColorEditorTests.moc"
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's colour runs in a text style: arithmetic and validity.
|
||||
namespace {
|
||||
const PaletteColor red{1, 0, 0};
|
||||
const PaletteColor blue{0, 0, 1};
|
||||
const PaletteColor green{0, 1, 0};
|
||||
|
||||
LayerTextColorRun run(qint64 location, qint64 length, const PaletteColor &color)
|
||||
{
|
||||
return {location, length, color.red, color.green, color.blue};
|
||||
}
|
||||
|
||||
std::unique_ptr<EditorSession> makeSession()
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(800, 600, true);
|
||||
session->selectTool(NavigationTool::type);
|
||||
return session;
|
||||
}
|
||||
|
||||
void edit(EditorSession &session, const QString &content, TextSpan selection)
|
||||
{
|
||||
TextDraft draft = session.textDraft().value();
|
||||
draft.style.content = content;
|
||||
draft.selection = selection;
|
||||
session.setTextDraft(draft);
|
||||
}
|
||||
}
|
||||
|
||||
class TextColorModelTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void colorAppliesToSelectionAndFollowsEdits();
|
||||
void invalidColorRunsAreRejected();
|
||||
void everyChannelOfARunIsChecked();
|
||||
void runsOfOneColourJoinOnlyWhereTheyMeet();
|
||||
};
|
||||
|
||||
void TextColorModelTests::colorAppliesToSelectionAndFollowsEdits()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Hello world");
|
||||
style.setColor(red, {6, 5});
|
||||
QCOMPARE(style.colorRuns.value(), (std::vector{run(6, 5, red)}));
|
||||
QVERIFY(style.color(5) == PaletteColor::black() && style.color(6) == red);
|
||||
// Painting beside a run in its colour joins it.
|
||||
style.setColor(red, {5, 1});
|
||||
QVERIFY(style.colorRuns.value().size() == 1 && style.colorRuns.value().front().location == 5);
|
||||
// Typed letters take the colour before them; deleted ones go.
|
||||
style.replaceCharacters({11, 0}, 1);
|
||||
style.content += u'!';
|
||||
QVERIFY(style.isValid() && style.color(11) == red);
|
||||
style.replaceCharacters({0, 2}, 0);
|
||||
style.content.remove(0, 2);
|
||||
QVERIFY(style.isValid() && style.colorRuns.value().front().location == 3 && style.colorRuns.value().front().length == 7);
|
||||
// Typed at the start: the first letter's colour.
|
||||
style.replaceCharacters({0, 0}, 2);
|
||||
style.content.prepend(QStringLiteral("ab"));
|
||||
QVERIFY(style.color(0) == PaletteColor::black() && style.colorRuns.value().front().location == 5);
|
||||
// No selection, or all of it, recolours the whole text.
|
||||
style.setColor(red, {4, 0});
|
||||
QVERIFY(!style.colorRuns && style.red == 1);
|
||||
style.setColor(blue, {0, style.content.size()});
|
||||
QVERIFY(!style.colorRuns && style.blue == 1 && style.red == 0);
|
||||
// Past the end, a span is clamped to the text.
|
||||
style.setColor(red, {3, 900});
|
||||
QCOMPARE(style.colorRuns.value(), (std::vector{run(3, style.content.size() - 3, red)}));
|
||||
// Runs matching the base colour vanish.
|
||||
style.setColor(blue, {3, 900});
|
||||
QVERIFY(!style.colorRuns);
|
||||
// Without runs, an edit keeps none.
|
||||
style.replaceCharacters({0, 0}, 3);
|
||||
QVERIFY(!style.colorRuns);
|
||||
}
|
||||
|
||||
|
||||
void TextColorModelTests::invalidColorRunsAreRejected()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Text");
|
||||
style.colorRuns = std::vector{run(2, 3, red)};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{run(0, 2, red), run(1, 2, PaletteColor{0, 1, 0})};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{LayerTextColorRun{0, 1, 2, 0, 0}};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{run(1, 0, red)};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{run(-1, 2, red)};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{run(1, std::numeric_limits<qint64>::max(), red)};
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector<LayerTextColorRun>();
|
||||
QVERIFY(!style.isValid());
|
||||
style.colorRuns = std::vector{run(0, 1, red), run(3, 1, blue)};
|
||||
QVERIFY(style.isValid());
|
||||
}
|
||||
|
||||
|
||||
void TextColorModelTests::everyChannelOfARunIsChecked()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Text");
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
for (const double value : {-0.01, 1.01, std::nan(""), std::numeric_limits<double>::infinity(), 0.0, 1.0}) {
|
||||
LayerTextColorRun run{0, 1, 0.5, 0.5, 0.5};
|
||||
(channel == 0 ? run.red : channel == 1 ? run.green : run.blue) = value;
|
||||
style.colorRuns = std::vector{run};
|
||||
const bool valid = value == 0 || value == 1;
|
||||
QVERIFY2(style.isValid() == valid, qPrintable(QStringLiteral("channel %1 at %2").arg(channel).arg(value)));
|
||||
}
|
||||
}
|
||||
// The store refuses an invalid run on saving too.
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("Text"), {0, 1});
|
||||
session->setPaletteColor(red, false);
|
||||
QVERIFY(session->finishText());
|
||||
ProjectSnapshot snapshot = session->projectSnapshot().value();
|
||||
snapshot.manifest.layers.back().text->colorRuns->front().blue = 2;
|
||||
QTemporaryDir root;
|
||||
try {
|
||||
ProjectStore::save(snapshot, root.filePath(QStringLiteral("Bad.comp")));
|
||||
QFAIL("an invalid run saved");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.kind, ProjectError::Kind::invalid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void TextColorModelTests::runsOfOneColourJoinOnlyWhereTheyMeet()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("abcde");
|
||||
style.setColor(green, {0, 1});
|
||||
style.setColor(green, {4, 1});
|
||||
// A base-coloured gap keeps two runs apart.
|
||||
QCOMPARE(style.colorRuns.value(), (std::vector{run(0, 1, green), run(4, 1, green)}));
|
||||
QVERIFY(style.color(2) == PaletteColor::black());
|
||||
// Split by the base colour, then repainted: one run again.
|
||||
style.setColor(green, {0, 5});
|
||||
style.setColor(red, {0, 5});
|
||||
style.setColor(green, {1, 3});
|
||||
style.setColor(red, {2, 1});
|
||||
QCOMPARE(style.colorRuns.value(), (std::vector{run(1, 1, green), run(3, 1, green)}));
|
||||
style.setColor(green, {2, 1});
|
||||
QCOMPARE(style.colorRuns.value(), (std::vector{run(1, 3, green)}));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextColorModelTests)
|
||||
#include "TextColorModelTests.moc"
|
||||
@@ -0,0 +1,398 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.3.2: only the selected letters take a colour.
|
||||
namespace {
|
||||
const PaletteColor red{1, 0, 0};
|
||||
const PaletteColor blue{0, 0, 1};
|
||||
|
||||
std::unique_ptr<EditorSession> makeSession()
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(800, 600, true);
|
||||
session->selectTool(NavigationTool::type);
|
||||
return session;
|
||||
}
|
||||
|
||||
// Swift writes the draft's content and selection in place.
|
||||
void edit(EditorSession &session, const QString &content, TextSpan selection)
|
||||
{
|
||||
TextDraft draft = session.textDraft().value();
|
||||
draft.style.content = content;
|
||||
draft.selection = selection;
|
||||
session.setTextDraft(draft);
|
||||
}
|
||||
|
||||
void pick(EditorSession &session, const PaletteColor &color)
|
||||
{
|
||||
PickerHSB hsb = session.colorPicker().value().hsb;
|
||||
hsb.setRGB(color);
|
||||
session.setColorPickerHSB(hsb);
|
||||
}
|
||||
|
||||
// Opaque pixels mostly red, and those that are dark.
|
||||
std::pair<int, int> redAndDark(const QImage &image)
|
||||
{
|
||||
int reds = 0, darks = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const QColor colour = image.pixelColor(x, y);
|
||||
if (colour.alphaF() <= 0.9f)
|
||||
continue;
|
||||
if (colour.redF() > 0.8f && colour.greenF() < 0.2f)
|
||||
++reds;
|
||||
else if (colour.redF() < 0.2f)
|
||||
++darks;
|
||||
}
|
||||
}
|
||||
return {reds, darks};
|
||||
}
|
||||
|
||||
LayerTextColorRun run(qint64 location, qint64 length, const PaletteColor &color)
|
||||
{
|
||||
return {location, length, color.red, color.green, color.blue};
|
||||
}
|
||||
}
|
||||
|
||||
class TextColorRunsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void selectedColorPaintsOnlyThoseLettersAndSurvivesReopening();
|
||||
void cancelingPickerRestoresSelectionColors();
|
||||
void theSwatchShowsTheSelectionOrTheLetterBeforeTheCaret();
|
||||
void theTypeBarsPickerPaintsTheSelectionAndCancelsBack();
|
||||
void fillAndNewTextStartInOneColour();
|
||||
void theManifestWritesRunsOnlyWhenThere();
|
||||
void aRunInALaterParagraphColoursItsOwnLetters();
|
||||
void runsDecodeAsSwiftsCodable();
|
||||
void cancelPutsBackMixedColoursForBothPickers();
|
||||
};
|
||||
|
||||
namespace {
|
||||
// A saved text layer with a run, and its manifest.
|
||||
struct SavedRun {
|
||||
QTemporaryDir root;
|
||||
QString path = root.filePath(QStringLiteral("Run.comp"));
|
||||
QJsonObject manifest;
|
||||
SavedRun()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("Abcd"), {1, 2});
|
||||
session->setPaletteColor(PaletteColor{0.25, 0.5, 0.75}, false);
|
||||
if (!session->finishText())
|
||||
throw std::runtime_error("the text was refused");
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
QFile file(path + QStringLiteral("/manifest.json"));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
throw std::runtime_error("no manifest");
|
||||
manifest = QJsonDocument::fromJson(file.readAll()).object();
|
||||
}
|
||||
// Loads the manifest with its text's runs replaced.
|
||||
std::optional<ProjectError::Kind> load(const std::function<void(QJsonObject &)> &change) const
|
||||
{
|
||||
QJsonObject edited = manifest;
|
||||
QJsonArray layers = edited.value("layers").toArray();
|
||||
QJsonObject layer = layers.last().toObject();
|
||||
QJsonObject text = layer.value("text").toObject();
|
||||
change(text);
|
||||
layer.insert("text", text);
|
||||
layers.replace(layers.size() - 1, layer);
|
||||
edited.insert("layers", layers);
|
||||
QFile file(path + QStringLiteral("/manifest.json"));
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
throw std::runtime_error("no manifest");
|
||||
file.write(QJsonDocument(edited).toJson());
|
||||
file.close();
|
||||
try {
|
||||
ProjectStore::load(path);
|
||||
return std::nullopt;
|
||||
} catch (const ProjectError &error) {
|
||||
return error.kind;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void TextColorRunsTests::selectedColorPaintsOnlyThoseLettersAndSurvivesReopening()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("AAAA BBBB"), {5, 4});
|
||||
session->openTextColorPicker();
|
||||
pick(*session, red);
|
||||
session->previewTextColor();
|
||||
session->closeColorPicker(true);
|
||||
QCOMPARE(session->textDraft().value().style.red, 0.0);
|
||||
QVERIFY(session->textDraft().value().style.color(5) == red);
|
||||
QVERIFY(session->finishText());
|
||||
const auto pixels = redAndDark(session->activeLayer().value().asset.value().image());
|
||||
QVERIFY2(pixels.first > 50 && pixels.second > 50, qPrintable(QStringLiteral("%1 %2").arg(pixels.first).arg(pixels.second)));
|
||||
const ProjectSnapshot snapshot = session->projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(10));
|
||||
QTemporaryDir root;
|
||||
const QString path = root.filePath(QStringLiteral("TextColors.comp"));
|
||||
ProjectStore::save(snapshot, path);
|
||||
EditorSession reopened;
|
||||
reopened.installProject(ProjectStore::load(path), path);
|
||||
const LayerText text = reopened.document().value().layers.back().liveText().value();
|
||||
QVERIFY(text.style == session->activeLayer().value().liveText().value().style);
|
||||
QCOMPARE(redAndDark(reopened.document().value().layers.back().asset.value().image()), pixels);
|
||||
// Version 9 cannot hold runs.
|
||||
QFile manifest(path + QStringLiteral("/manifest.json"));
|
||||
QVERIFY(manifest.open(QIODevice::ReadOnly));
|
||||
QJsonObject object = QJsonDocument::fromJson(manifest.readAll()).object();
|
||||
manifest.close();
|
||||
object.insert(QStringLiteral("version"), 9);
|
||||
QVERIFY(manifest.open(QIODevice::WriteOnly | QIODevice::Truncate));
|
||||
manifest.write(QJsonDocument(object).toJson());
|
||||
manifest.close();
|
||||
try {
|
||||
ProjectStore::load(path);
|
||||
QFAIL("version 9 with colour runs loaded");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.kind, ProjectError::Kind::invalid);
|
||||
}
|
||||
session->undo();
|
||||
QVERIFY(!session->activeLayer() || !session->activeLayer().value().liveText());
|
||||
}
|
||||
|
||||
void TextColorRunsTests::cancelingPickerRestoresSelectionColors()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("Two words"), {0, 3});
|
||||
session->setPaletteColor(red, false);
|
||||
const LayerTextStyle colored = session->textDraft().value().style;
|
||||
QCOMPARE(colored.colorRuns.value().size(), size_t(1));
|
||||
session->openColorPicker(false);
|
||||
pick(*session, blue);
|
||||
session->previewTextColor();
|
||||
QCOMPARE(session->textDraft().value().style.color(0).blue, 1.0);
|
||||
session->closeColorPicker(false);
|
||||
QVERIFY(session->textDraft().value().style == colored);
|
||||
// Retyped meanwhile, the runs cannot come back: one colour.
|
||||
session->openColorPicker(false);
|
||||
pick(*session, blue);
|
||||
session->previewTextColor();
|
||||
edit(*session, QStringLiteral("Other words"), {0, 0});
|
||||
session->closeColorPicker(false);
|
||||
const LayerTextStyle retyped = session->textDraft().value().style;
|
||||
QVERIFY(!retyped.colorRuns && retyped.red == colored.red && retyped.blue == colored.blue);
|
||||
// Nothing selected: the whole text previews, Cancel restores it.
|
||||
edit(*session, QStringLiteral("Other words"), {3, 0});
|
||||
session->openColorPicker(false);
|
||||
pick(*session, red);
|
||||
session->previewTextColor();
|
||||
QVERIFY(session->textDraft().value().style.red == 1 && !session->textDraft().value().style.colorRuns);
|
||||
session->closeColorPicker(false);
|
||||
QVERIFY(session->textDraft().value().style.red == 0 && session->textDraft().value().style.blue == 0);
|
||||
}
|
||||
|
||||
void TextColorRunsTests::theSwatchShowsTheSelectionOrTheLetterBeforeTheCaret()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
QVERIFY(session->typeColor() == session->foregroundColor());
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("abcdef"), {2, 2});
|
||||
session->setPaletteColor(red, false);
|
||||
// Selected: the first selected letter's colour.
|
||||
QVERIFY(session->typeColor() == red);
|
||||
edit(*session, QStringLiteral("abcdef"), {1, 3});
|
||||
QVERIFY(session->typeColor() == PaletteColor::black());
|
||||
// The Type bar's picker previews the selection; Cancel undoes.
|
||||
edit(*session, QStringLiteral("abcdef"), {4, 0});
|
||||
QVERIFY(session->typeColor() == red);
|
||||
edit(*session, QStringLiteral("abcdef"), {2, 0});
|
||||
QVERIFY(session->typeColor() == PaletteColor::black());
|
||||
edit(*session, QStringLiteral("abcdef"), {0, 0});
|
||||
QVERIFY(session->typeColor() == PaletteColor::black());
|
||||
}
|
||||
|
||||
void TextColorRunsTests::theTypeBarsPickerPaintsTheSelectionAndCancelsBack()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("AAAA BBBB"), {0, 4});
|
||||
const LayerTextStyle before = session->textDraft().value().style;
|
||||
// Fill recolours every letter, runs gone, even unchanged.
|
||||
session->openTextColorPicker();
|
||||
QVERIFY(session->colorPicker().value().editedText.has_value());
|
||||
pick(*session, blue);
|
||||
session->previewTextColor();
|
||||
QVERIFY(session->textDraft().value().style.color(0) == blue && session->textDraft().value().style.color(5) == PaletteColor::black());
|
||||
session->closeColorPicker(false);
|
||||
QVERIFY(session->textDraft().value().style == before);
|
||||
// OK paints the selection and moves the foreground.
|
||||
session->openTextColorPicker();
|
||||
pick(*session, blue);
|
||||
session->closeColorPicker(true);
|
||||
QVERIFY(session->textDraft().value().style.color(3) == blue && session->textDraft().value().style.color(4) == PaletteColor::black());
|
||||
QVERIFY(session->foregroundColor() == blue);
|
||||
}
|
||||
|
||||
void TextColorRunsTests::fillAndNewTextStartInOneColour()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("Two words"), {4, 5});
|
||||
session->setPaletteColor(red, false);
|
||||
QVERIFY(session->finishText());
|
||||
const QUuid id = session->activeLayerID().value();
|
||||
// The defaults keep the style, never the runs.
|
||||
QVERIFY(!session->textDefaults().colorRuns);
|
||||
// The next text holds no runs of the last.
|
||||
session->beginText(QPointF(300, 300), true);
|
||||
QVERIFY(!session->textDraft().value().style.colorRuns);
|
||||
session->cancelText();
|
||||
// A run's keys, as Swift's Codable writes them.
|
||||
QVERIFY(session->recolorText(id, PaletteColor::black()));
|
||||
const LayerTextStyle filled = session->activeLayer().value().liveText().value().style;
|
||||
QVERIFY(!filled.colorRuns && filled.red == 0);
|
||||
QCOMPARE(session->history.undoName(), QString("Fill Text"));
|
||||
QCOMPARE(redAndDark(session->activeLayer().value().asset.value().image()).first, 0);
|
||||
}
|
||||
|
||||
void TextColorRunsTests::theManifestWritesRunsOnlyWhenThere()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("Plain"), {0, 0});
|
||||
QVERIFY(session->finishText());
|
||||
QTemporaryDir root;
|
||||
const QString path = root.filePath(QStringLiteral("Plain.comp"));
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
QFile manifest(path + QStringLiteral("/manifest.json"));
|
||||
QVERIFY(manifest.open(QIODevice::ReadOnly));
|
||||
const QByteArray bytes = manifest.readAll();
|
||||
QVERIFY(!bytes.contains("colorRuns"));
|
||||
// A run's keys, as Swift's Codable writes them.
|
||||
session->editActiveText();
|
||||
edit(*session, QStringLiteral("Plain"), {1, 2});
|
||||
session->setPaletteColor(red, false);
|
||||
QVERIFY(session->finishText());
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
manifest.close();
|
||||
QVERIFY(manifest.open(QIODevice::ReadOnly));
|
||||
const QJsonObject object = QJsonDocument::fromJson(manifest.readAll()).object();
|
||||
const QJsonObject text = object.value("layers").toArray().last().toObject().value("text").toObject();
|
||||
QCOMPARE(text.value("colorRuns").toArray(), (QJsonArray{QJsonObject{{"location", 1}, {"length", 2}, {"red", 1}, {"green", 0}, {"blue", 0}}}));
|
||||
}
|
||||
|
||||
void TextColorRunsTests::aRunInALaterParagraphColoursItsOwnLetters()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
edit(*session, QStringLiteral("MM\nMM"), {3, 2});
|
||||
session->setPaletteColor(red, false);
|
||||
QCOMPARE(session->textDraft().value().style.colorRuns.value(), (std::vector{run(3, 2, red)}));
|
||||
QVERIFY(session->finishText());
|
||||
const QImage image = session->activeLayer().value().asset.value().image();
|
||||
// The first line dark, the second red.
|
||||
const auto [topRed, topDark] = redAndDark(image.copy(0, 0, image.width(), image.height() / 2));
|
||||
const auto [lowRed, lowDark] = redAndDark(image.copy(0, image.height() / 2, image.width(), image.height() - image.height() / 2));
|
||||
QVERIFY2(topRed == 0 && topDark > 50 && lowRed > 50 && lowDark == 0,
|
||||
qPrintable(QStringLiteral("%1 %2 %3 %4").arg(topRed).arg(topDark).arg(lowRed).arg(lowDark)));
|
||||
// A run across the break: each line its own share.
|
||||
session->editActiveText();
|
||||
edit(*session, QStringLiteral("MM\nMM"), {1, 3});
|
||||
session->setPaletteColor(PaletteColor{0, 1, 0}, false);
|
||||
QVERIFY(session->finishText());
|
||||
const QImage across = session->activeLayer().value().asset.value().image();
|
||||
const auto count = [&across](bool upper, int channel) {
|
||||
int found = 0;
|
||||
const int from = upper ? 0 : across.height() / 2, to = upper ? across.height() / 2 : across.height();
|
||||
for (int y = from; y < to; ++y) {
|
||||
for (int x = 0; x < across.width(); ++x) {
|
||||
const QColor colour = across.pixelColor(x, y);
|
||||
const std::array<float, 3> rgb{colour.redF(), colour.greenF(), colour.blueF()};
|
||||
const bool dark = channel < 0 && rgb[0] < 0.2f && rgb[1] < 0.2f;
|
||||
found += colour.alphaF() > 0.9f && (dark || (channel >= 0 && rgb[size_t(channel)] > 0.8f && rgb[size_t(1 - channel)] < 0.2f));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
// Upper: dark then green; lower: green then red.
|
||||
QVERIFY(count(true, -1) > 50 && count(true, 1) > 50 && count(true, 0) == 0);
|
||||
QVERIFY(count(false, -1) == 0 && count(false, 1) > 50 && count(false, 0) > 50);
|
||||
}
|
||||
|
||||
void TextColorRunsTests::runsDecodeAsSwiftsCodable()
|
||||
{
|
||||
const SavedRun saved;
|
||||
const QJsonArray written = saved.manifest.value("layers").toArray().last().toObject().value("text").toObject().value("colorRuns").toArray();
|
||||
QCOMPARE(written, (QJsonArray{QJsonObject{{"location", 1}, {"length", 2}, {"red", 0.25}, {"green", 0.5}, {"blue", 0.75}}}));
|
||||
const LayerTextStyle loaded = ProjectStore::load(saved.path).manifest.layers.back().text.value();
|
||||
QCOMPARE(loaded.colorRuns.value(), (std::vector{LayerTextColorRun{1, 2, 0.25, 0.5, 0.75}}));
|
||||
const auto withRun = [&](const std::function<void(QJsonObject &)> &change) {
|
||||
return saved.load([&](QJsonObject &text) {
|
||||
QJsonObject run = written.first().toObject();
|
||||
change(run);
|
||||
text.insert("colorRuns", QJsonArray{run});
|
||||
});
|
||||
};
|
||||
const std::optional<ProjectError::Kind> invalid = ProjectError::Kind::invalid;
|
||||
// Each of the five keys required, each a number.
|
||||
for (const char *key : {"location", "length", "red", "green", "blue"}) {
|
||||
QCOMPARE(withRun([key](QJsonObject &run) { run.remove(key); }), invalid);
|
||||
QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, QStringLiteral("1")); }), invalid);
|
||||
QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, QJsonValue::Null); }), invalid);
|
||||
QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, true); }), invalid);
|
||||
}
|
||||
// Offsets are whole Int64s.
|
||||
QCOMPARE(withRun([](QJsonObject &run) { run.insert("location", 1.5); }), invalid);
|
||||
QCOMPARE(withRun([](QJsonObject &run) { run.insert("length", 1e19); }), invalid);
|
||||
// Unknown keys pass.
|
||||
QCOMPARE(withRun([](QJsonObject &run) { run.insert("alpha", 1); }), std::nullopt);
|
||||
// Not an array, or an entry no object: refused.
|
||||
QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonObject()); }), invalid);
|
||||
QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonArray{5}); }), invalid);
|
||||
// Absent or null: one colour.
|
||||
QCOMPARE(saved.load([](QJsonObject &text) { text.remove("colorRuns"); }), std::nullopt);
|
||||
QVERIFY(!ProjectStore::load(saved.path).manifest.layers.back().text.value().colorRuns);
|
||||
QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonValue::Null); }), std::nullopt);
|
||||
QVERIFY(!ProjectStore::load(saved.path).manifest.layers.back().text.value().colorRuns);
|
||||
}
|
||||
|
||||
void TextColorRunsTests::cancelPutsBackMixedColoursForBothPickers()
|
||||
{
|
||||
for (const bool typeBar : {false, true}) {
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
// A blue base; red and green in the coming selection.
|
||||
edit(*session, QStringLiteral("abcdef"), {0, 0});
|
||||
session->setPaletteColor(blue, false);
|
||||
edit(*session, QStringLiteral("abcdef"), {1, 1});
|
||||
session->setPaletteColor(red, false);
|
||||
edit(*session, QStringLiteral("abcdef"), {2, 1});
|
||||
session->setPaletteColor(PaletteColor{0, 1, 0}, false);
|
||||
edit(*session, QStringLiteral("abcdef"), {1, 3});
|
||||
const LayerTextStyle original = session->textDraft().value().style;
|
||||
QCOMPARE(original.colorRuns.value().size(), size_t(2));
|
||||
const auto open = [&] { typeBar ? session->openTextColorPicker() : session->openColorPicker(false); };
|
||||
open();
|
||||
pick(*session, PaletteColor{1, 1, 0});
|
||||
session->previewTextColor();
|
||||
QVERIFY(session->textDraft().value().style.color(1) == (PaletteColor{1, 1, 0}));
|
||||
session->closeColorPicker(false);
|
||||
QVERIFY2(session->textDraft().value().style == original, typeBar ? "type bar" : "palette");
|
||||
// Retyped meanwhile: the new text stays, the base comes back.
|
||||
open();
|
||||
pick(*session, PaletteColor{1, 1, 0});
|
||||
session->previewTextColor();
|
||||
edit(*session, QStringLiteral("xyz"), {0, 0});
|
||||
session->closeColorPicker(false);
|
||||
const LayerTextStyle retyped = session->textDraft().value().style;
|
||||
QVERIFY(retyped.content == "xyz" && !retyped.colorRuns);
|
||||
QVERIFY((PaletteColor{retyped.red, retyped.green, retyped.blue}) == blue);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextColorRunsTests)
|
||||
#include "TextColorRunsTests.moc"
|
||||
@@ -80,13 +80,18 @@ private slots:
|
||||
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted_data()
|
||||
{
|
||||
QTest::addColumn<double>("zoom");
|
||||
QTest::newRow("smooth") << 1.5;
|
||||
QTest::newRow("hard") << 4.0;
|
||||
QTest::addColumn<bool>("coloured");
|
||||
QTest::newRow("smooth") << 1.5 << false;
|
||||
QTest::newRow("hard") << 4.0 << false;
|
||||
// Letters in their own colours: the layer draws them.
|
||||
QTest::newRow("coloured smooth") << 1.5 << true;
|
||||
QTest::newRow("coloured hard") << 4.0 << true;
|
||||
}
|
||||
|
||||
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
|
||||
{
|
||||
QFETCH(double, zoom);
|
||||
QFETCH(bool, coloured);
|
||||
DrawnText shown;
|
||||
shown.session.zoom(zoom);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
@@ -97,6 +102,8 @@ void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
|
||||
style.content = QStringLiteral("Sharp");
|
||||
style.fontSize = 24;
|
||||
});
|
||||
if (coloured)
|
||||
shown.session.changeTextStyle([](LayerTextStyle &style) { style.setColor(PaletteColor{0, 1, 0}, {1, 3}); });
|
||||
// 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);
|
||||
|
||||
Reference in new issue
Block a user