Colour only the selected letters of text being edited; project format version 10 (12.14d1)

This commit is contained in:
雷电芽衣 committed 2026-09-30 05:15:51 -04:00
1 parent 2fa8d9921d
commit 27a4fe8428
32 files changed
+1398 -82

No files matched your search

+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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(),
+2 -2
View File
@@ -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),
+1 -1
View File
@@ -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();
+11 -2
View File
@@ -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());
+1 -1
View File
@@ -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
View File
@@ -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));
+3
View File
@@ -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)
+475
View File
@@ -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"
+155
View File
@@ -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"
+398
View File
@@ -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"
+9 -2
View File
@@ -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);