Faces for the selected letters, project format version 11 (13.2)
This commit is contained in:
1 parent
802ae82f72
commit
dcedfea53f
29 files changed
+869
-139
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(10));
|
||||
QCOMPARE(loaded.manifest.version, qint64(11));
|
||||
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(10));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(11));
|
||||
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(11);
|
||||
const ProjectError version = ProjectError::unsupportedVersion(12);
|
||||
QCOMPARE(version.kind, ProjectError::Kind::version);
|
||||
QCOMPARE(version.version, std::optional<int>(11));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 11. This app supports versions 1–10."));
|
||||
QCOMPARE(version.version, std::optional<int>(12));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 12. This app supports versions 1–11."));
|
||||
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(10));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(11));
|
||||
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(10));
|
||||
QCOMPARE(loaded.manifest.version, qint64(11));
|
||||
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(10));
|
||||
QCOMPARE(loaded.manifest.version, qint64(11));
|
||||
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 == 10 && loaded.masks.count(folder) == 1);
|
||||
QVERIFY(loaded.manifest.version == 11 && 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),
|
||||
|
||||
@@ -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 11") << "" << 11 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 12") << "" << 12 << 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;
|
||||
@@ -369,6 +369,8 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
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;
|
||||
QTest::newRow("letters in faces before version 11") << "textFonts" << 10 << invalid;
|
||||
QTest::newRow("letters in faces at version 11") << "textFonts" << 11 << fine;
|
||||
}
|
||||
|
||||
void ProjectManifestTests::validationFollowsTheFormatsVersions()
|
||||
@@ -458,11 +460,13 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions()
|
||||
image.imageFile = "image.png";
|
||||
else if (change == "imageLower")
|
||||
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
|
||||
else if (change == "text" || change == "textRuns") {
|
||||
else if (change == "text" || change == "textRuns" || change == "textFonts") {
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Ab");
|
||||
if (change == "textRuns")
|
||||
style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
|
||||
if (change == "textFonts")
|
||||
style.fontRuns = std::vector{LayerTextFontRun{1, 1, QStringLiteral("DejaVuSansMono")}};
|
||||
image.text = style;
|
||||
} else if (change == "active")
|
||||
manifest.activeLayerID = QUuid::createUuid();
|
||||
|
||||
@@ -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(10));
|
||||
QCOMPARE(again.manifest.version, qint64(11));
|
||||
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, 10)));
|
||||
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 11)));
|
||||
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
|
||||
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
|
||||
QCOMPARE(QString(ProjectError::unsupportedVersion(11).what()), QString("This project uses format version 11. This app supports versions 1–10."));
|
||||
QCOMPARE(QString(ProjectError::unsupportedVersion(12).what()), QString("This project uses format version 12. This app supports versions 1–11."));
|
||||
}
|
||||
|
||||
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\": 10"));
|
||||
QVERIFY(good.contains("\"version\": 11"));
|
||||
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(11), qint64(0)}) {
|
||||
for (const qint64 strange : {qint64(42), qint64(12), 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\": 10", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"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\": 10", "\"version\": " + QByteArray::number(wild)));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"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\": 10", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
// Declared: Qt rounds a token just past Int64's minimum.
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": -9223372036854775809"));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"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\": 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("\"version\": 11", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"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));
|
||||
|
||||
@@ -173,6 +173,8 @@ compositor_test(TextColorRunsTests)
|
||||
compositor_test(TextColorModelTests)
|
||||
compositor_test(TextColorEditorTests)
|
||||
compositor_test(TextEditingGatesTests)
|
||||
compositor_test(TextFontRunsTests)
|
||||
compositor_test(TextFontControlsTests)
|
||||
compositor_test(DitherTests)
|
||||
compositor_test(DitherSheetTests)
|
||||
compositor_test(DitherKernelTests)
|
||||
|
||||
@@ -44,6 +44,10 @@ void TextColorModelTests::colorAppliesToSelectionAndFollowsEdits()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Hello world");
|
||||
// The whole text takes every channel of its colour.
|
||||
style.setColor(PaletteColor{0.25, 0.5, 0.75}, {0, 0});
|
||||
QVERIFY(style.red == 0.25 && style.green == 0.5 && style.blue == 0.75 && !style.colorRuns);
|
||||
style.setColor(PaletteColor::black(), {0, 11});
|
||||
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);
|
||||
|
||||
@@ -135,7 +135,7 @@ void TextColorRunsTests::selectedColorPaintsOnlyThoseLettersAndSurvivesReopening
|
||||
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));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(11));
|
||||
QTemporaryDir root;
|
||||
const QString path = root.filePath(QStringLiteral("TextColors.comp"));
|
||||
ProjectStore::save(snapshot, path);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#include "InlineTextDrawFixtures.h"
|
||||
#include "UI/TypeControls.h"
|
||||
#include <QAbstractItemView>
|
||||
#include <QFontMetricsF>
|
||||
#include "Rendering/TextLayout.h"
|
||||
|
||||
// Swift 1.3.4's font menu: the selection's face, or (Multiple).
|
||||
namespace {
|
||||
const QString sans = QStringLiteral("DejaVuSans");
|
||||
const QString mono = QStringLiteral("DejaVuSansMono");
|
||||
|
||||
// Typed text with a menu beside the canvas.
|
||||
struct Typed : DrawnText {
|
||||
TypeControls *const bar = new TypeControls(session, &window);
|
||||
TypeFontPicker &menu() const { return *bar->findChild<TypeFontPicker *>(QStringLiteral("typeFont")); }
|
||||
Typed()
|
||||
{
|
||||
bar->setGeometry(0, 300, 400, 40);
|
||||
bar->show();
|
||||
session.changeTextStyle([](LayerTextStyle &style) { style.fontName = sans; });
|
||||
canvas->setFocus();
|
||||
beginTextAt(session, QPointF(20, 30));
|
||||
QTest::keyClicks(canvas, QStringLiteral("Hello"));
|
||||
}
|
||||
const LayerTextStyle &style() const { return session.textDraft().value().style; }
|
||||
// The menu chooses a face, as a click does.
|
||||
void choose(const QString &face)
|
||||
{
|
||||
if (menu().findText(face) < 0) {
|
||||
menu().showPopup();
|
||||
menu().hidePopup();
|
||||
}
|
||||
emit menu().activated(menu().findText(face));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class TextFontControlsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theMenuShowsTheCaretsFaceOrMultiple();
|
||||
void aChosenFaceTakesTheSelectionTheMenuKept();
|
||||
void typingAndUndoKeepTheFaces();
|
||||
void theLoadedMenuKeepsMultipleOnTop();
|
||||
void deletingToOneFaceKeepsItsLetters();
|
||||
void theEditorPlacesTheCaretByTheFaces();
|
||||
};
|
||||
|
||||
void TextFontControlsTests::theMenuShowsTheCaretsFaceOrMultiple()
|
||||
{
|
||||
Typed typed;
|
||||
typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
|
||||
// The caret shows the letter before it.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right);
|
||||
QCOMPARE(typed.menu().currentText(), mono);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right);
|
||||
QCOMPARE(typed.menu().currentText(), mono);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right);
|
||||
QCOMPARE(typed.menu().currentText(), sans);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
QCOMPARE(typed.menu().currentText(), sans);
|
||||
// At the start, the first letter.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Home);
|
||||
QCOMPARE(typed.menu().currentText(), mono);
|
||||
// Across both faces: (Multiple), on top, no font.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
QCOMPARE(typed.menu().currentIndex(), 0);
|
||||
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
|
||||
QVERIFY(typed.menu().isMultiple(0));
|
||||
// Still mixed: the item stays one.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
|
||||
QVERIFY(!typed.menu().isMultiple(1));
|
||||
// One face again: the item goes.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QCOMPARE(typed.menu().currentText(), sans);
|
||||
QVERIFY(!typed.menu().isMultiple(0) && typed.menu().findText(QStringLiteral("(Multiple)")) < 0);
|
||||
}
|
||||
|
||||
void TextFontControlsTests::aChosenFaceTakesTheSelectionTheMenuKept()
|
||||
{
|
||||
Typed typed;
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
// The menu takes the keys; the letters stay selected.
|
||||
typed.menu().setFocus(Qt::MouseFocusReason);
|
||||
QTRY_VERIFY(!typed.canvas->hasFocus());
|
||||
QCOMPARE(typed.session.textDraft().value().selection, (TextSpan{3, 2}));
|
||||
QVERIFY(typed.editor().anchor() == 5 && typed.editor().caretPosition() == 3);
|
||||
typed.choose(mono);
|
||||
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 2, mono}}));
|
||||
QCOMPARE(typed.style().fontName, sans);
|
||||
// The face shown, or (Multiple), changes nothing.
|
||||
QSignalSpy changed(&typed.session, &EditorSession::changed);
|
||||
typed.choose(mono);
|
||||
QCOMPARE(int(changed.count()), 0);
|
||||
typed.canvas->setFocus();
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
|
||||
const int heard = int(changed.count());
|
||||
emit typed.menu().activated(0);
|
||||
QCOMPARE(int(changed.count()), heard);
|
||||
// From (Multiple), any face takes the whole selection.
|
||||
typed.choose(sans);
|
||||
QVERIFY(!typed.style().fontRuns && typed.style().fontName == sans);
|
||||
// A caret alone sets the whole text's face.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
typed.choose(mono);
|
||||
QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
|
||||
}
|
||||
|
||||
void TextFontControlsTests::typingAndUndoKeepTheFaces()
|
||||
{
|
||||
Typed typed;
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
typed.choose(mono);
|
||||
// Typed after mono, mono; after sans, sans.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
QTest::keyClicks(typed.canvas, QStringLiteral("!"));
|
||||
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right);
|
||||
QTest::keyClicks(typed.canvas, QStringLiteral("x"));
|
||||
QCOMPARE(typed.style().content, QStringLiteral("Hxello!"));
|
||||
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{4, 3, mono}}));
|
||||
// Undo takes the typing back, the faces with it.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Z, Qt::ControlModifier);
|
||||
QCOMPARE(typed.style().content, QStringLiteral("Hello!"));
|
||||
QCOMPARE(typed.style().fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
|
||||
// Applied, the layer keeps them.
|
||||
QVERIFY(typed.session.finishText());
|
||||
QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontRuns.value(), (std::vector<LayerTextFontRun>{{3, 3, mono}}));
|
||||
}
|
||||
|
||||
void TextFontControlsTests::theLoadedMenuKeepsMultipleOnTop()
|
||||
{
|
||||
Typed typed;
|
||||
typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
|
||||
QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
|
||||
QCOMPARE(typed.menu().currentText(), QStringLiteral("(Multiple)"));
|
||||
typed.menu().showPopup();
|
||||
QVERIFY(typed.menu().isMultiple(0) && typed.menu().currentIndex() == 0);
|
||||
QVERIFY(typed.menu().findText(mono) > 0 && typed.menu().findText(sans) > 0);
|
||||
typed.menu().hidePopup();
|
||||
// A single face after loading drops the item again.
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
QTRY_COMPARE(typed.menu().currentText(), sans);
|
||||
QVERIFY(!typed.menu().isMultiple(0));
|
||||
}
|
||||
|
||||
// A fix beyond Swift, whose draft drops the folded face.
|
||||
void TextFontControlsTests::deletingToOneFaceKeepsItsLetters()
|
||||
{
|
||||
Typed typed;
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Home);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Right, Qt::ShiftModifier);
|
||||
typed.choose(mono);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_End);
|
||||
for (int letter = 0; letter < 3; ++letter)
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Backspace);
|
||||
QCOMPARE(typed.style().content, QStringLiteral("He"));
|
||||
QVERIFY(!typed.style().fontRuns && typed.style().fontName == mono);
|
||||
QVERIFY(typed.session.finishText());
|
||||
QCOMPARE(typed.session.activeLayer().value().liveText().value().style.fontName, mono);
|
||||
}
|
||||
|
||||
// A click lands by the faces the letters show.
|
||||
void TextFontControlsTests::theEditorPlacesTheCaretByTheFaces()
|
||||
{
|
||||
Typed typed;
|
||||
QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
|
||||
QTest::keyClicks(typed.canvas, QStringLiteral("iiii"));
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
QTest::keyClick(typed.canvas, Qt::Key_Left, Qt::ShiftModifier);
|
||||
typed.choose(mono);
|
||||
const double narrow = QFontMetricsF(TextLayout::font(typed.style(), sans)).horizontalAdvance(QLatin1Char('i'));
|
||||
const double wide = QFontMetricsF(TextLayout::font(typed.style(), mono)).horizontalAdvance(QLatin1Char('i'));
|
||||
QVERIFY(wide > 2 * narrow);
|
||||
// A fifth into the last letter: before it, in mono.
|
||||
const double x = LayerTextStyle::padding + 2 * narrow + wide + wide / 5;
|
||||
const QPointF at = typed.editor().textTransform().map(QPointF(x, LayerTextStyle::padding + typed.style().lineHeight() / 2));
|
||||
typed.click(at);
|
||||
QCOMPARE(typed.editor().caretPosition(), 3);
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextFontControlsTests)
|
||||
#include "TextFontControlsTests.moc"
|
||||
@@ -0,0 +1,290 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "Rendering/TextLayout.h"
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QFontInfo>
|
||||
#include <QFontMetricsF>
|
||||
#include <limits>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.3.4: a font change applies to the selected letters.
|
||||
namespace {
|
||||
const QString sans = QStringLiteral("DejaVuSans");
|
||||
const QString mono = QStringLiteral("DejaVuSansMono");
|
||||
|
||||
std::unique_ptr<EditorSession> makeSession()
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(800, 600, true);
|
||||
session->selectTool(NavigationTool::type);
|
||||
return session;
|
||||
}
|
||||
|
||||
LayerTextStyle hello()
|
||||
{
|
||||
LayerTextStyle style;
|
||||
style.content = QStringLiteral("Hello");
|
||||
style.fontName = sans;
|
||||
return style;
|
||||
}
|
||||
|
||||
// A saved text layer whose first two letters are mono.
|
||||
struct SavedFonts {
|
||||
QTemporaryDir root;
|
||||
QString path = root.filePath(QStringLiteral("Fonts.comp"));
|
||||
SavedFonts()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
TextDraft draft = session->textDraft().value();
|
||||
draft.style.content = QStringLiteral("Hello");
|
||||
draft.style.fontName = sans;
|
||||
draft.selection = {0, 2};
|
||||
session->setTextDraft(draft);
|
||||
session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
|
||||
if (!session->finishText())
|
||||
throw std::runtime_error("the text was refused");
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
}
|
||||
// The manifest with its text's runs and version rewritten.
|
||||
std::optional<ProjectError::Kind> load(qint64 version, const QJsonValue &fontRuns) const
|
||||
{
|
||||
QFile file(path + QStringLiteral("/manifest.json"));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
throw std::runtime_error("no manifest");
|
||||
QJsonObject manifest = QJsonDocument::fromJson(file.readAll()).object();
|
||||
file.close();
|
||||
manifest["version"] = version;
|
||||
QJsonArray layers = manifest["layers"].toArray();
|
||||
const qsizetype index = layers.size() - 1;
|
||||
QJsonObject layer = layers[index].toObject();
|
||||
if (!layer.contains("text"))
|
||||
throw std::runtime_error("the top layer holds no text");
|
||||
QJsonObject text = layer["text"].toObject();
|
||||
text["fontRuns"] = fontRuns;
|
||||
layer["text"] = text;
|
||||
layers[index] = layer;
|
||||
manifest["layers"] = layers;
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(QJsonDocument(manifest).toJson()) < 0)
|
||||
throw std::runtime_error("the manifest could not be written");
|
||||
file.close();
|
||||
try {
|
||||
ProjectStore::load(path);
|
||||
return std::nullopt;
|
||||
} catch (const ProjectError &error) {
|
||||
return error.kind;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
QJsonObject run(qint64 location, qint64 length, const QString &face)
|
||||
{
|
||||
return {{"location", location}, {"length", length}, {"fontName", face}};
|
||||
}
|
||||
}
|
||||
|
||||
class TextFontRunsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void fontAppliesToTheSelectionOnly();
|
||||
void selectedFontSurvivesReopening();
|
||||
void runsNeedVersionElevenAndAValidFace();
|
||||
void eachRunLaysOutInItsFace();
|
||||
void newTextStartsInOneFace();
|
||||
};
|
||||
|
||||
// Swift's test, with faces of different widths kept.
|
||||
void TextFontRunsTests::fontAppliesToTheSelectionOnly()
|
||||
{
|
||||
LayerTextStyle style = hello();
|
||||
style.setFont(mono, {0, 2});
|
||||
QCOMPARE(style.fontName, sans);
|
||||
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, mono}}));
|
||||
QVERIFY(style.fontNameAt(0) == mono && style.fontNameAt(2) == sans);
|
||||
const double mixed = EditorSession::textBoxSize(style).width();
|
||||
style.setFont(mono, {0, 0});
|
||||
QVERIFY(!style.fontRuns && style.fontName == mono);
|
||||
QVERIFY(EditorSession::textBoxSize(style).width() > mixed);
|
||||
style.setFont(sans, {1, 3});
|
||||
style.replaceCharacters({5, 0}, 1);
|
||||
style.content += QStringLiteral("!");
|
||||
QVERIFY(style.isValid() && style.fontNameAt(5) == mono);
|
||||
QCOMPARE(style.uniformFontName({0, 2}), std::optional<QString>());
|
||||
QCOMPARE(style.uniformFontName({1, 3}), std::optional(sans));
|
||||
QCOMPARE(style.uniformFontName({4, 2}), std::optional(mono));
|
||||
QCOMPARE(style.uniformFontName({2, 0}), std::optional<QString>());
|
||||
style.setFont(mono, {0, 6});
|
||||
QVERIFY(!style.fontRuns && style.fontName == mono);
|
||||
style.setFont(sans, {0, 2});
|
||||
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, sans}}));
|
||||
// A name that is no face changes nothing.
|
||||
style.setFont(QString(), {2, 1});
|
||||
style.setFont(QStringLiteral("Bad\nName"), {2, 1});
|
||||
QCOMPARE(style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, sans}}));
|
||||
// Same-face runs apart stay apart; a base gap mixes.
|
||||
LayerTextStyle apart = hello();
|
||||
apart.setFont(mono, {0, 1});
|
||||
apart.setFont(mono, {3, 1});
|
||||
QCOMPARE(apart.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {3, 1, mono}}));
|
||||
QCOMPARE(apart.uniformFontName({0, 4}), std::optional<QString>());
|
||||
QCOMPARE(apart.uniformFontName({3, 1}), std::optional(mono));
|
||||
// All letters in the run's face: the text's face.
|
||||
LayerTextStyle kept = hello();
|
||||
kept.setFont(mono, {0, 2});
|
||||
kept.replaceCharacters({2, 3}, 0);
|
||||
kept.content.truncate(2);
|
||||
QVERIFY(!kept.fontRuns && kept.fontName == mono);
|
||||
// Neighbouring runs in two faces stay two.
|
||||
const QString bold = QStringLiteral("DejaVuSans-Bold");
|
||||
LayerTextStyle three = hello();
|
||||
three.setFont(mono, {0, 1});
|
||||
three.setFont(bold, {1, 1});
|
||||
QCOMPARE(three.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {1, 1, bold}}));
|
||||
QVERIFY(three.fontNameAt(0) == mono && three.fontNameAt(1) == bold && three.fontNameAt(2) == sans);
|
||||
// A run starting at the span's end stays out.
|
||||
QCOMPARE(three.uniformFontName({0, 1}), std::optional(mono));
|
||||
QCOMPARE(three.uniformFontName({2, 3}), std::optional(sans));
|
||||
// A span past the end stops at it.
|
||||
three.setFont(mono, {3, 99});
|
||||
QCOMPARE(three.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 1, mono}, {1, 1, bold}, {3, 2, mono}}));
|
||||
QCOMPARE(three.uniformFontName({3, 99}), std::optional(mono));
|
||||
// Inserted at the start, letters take the first letter's face.
|
||||
LayerTextStyle first = hello();
|
||||
first.setFont(mono, {0, 1});
|
||||
first.replaceCharacters({0, 0}, 2);
|
||||
first.content.prepend(QStringLiteral("xy"));
|
||||
QCOMPARE(first.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 3, mono}}));
|
||||
first.replaceCharacters({0, 1}, 1);
|
||||
QCOMPARE(first.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 3, mono}}));
|
||||
// Deleting a run's letters drops the run.
|
||||
style.replaceCharacters({0, 2}, 0);
|
||||
style.content.remove(0, 2);
|
||||
QVERIFY(!style.fontRuns && style.fontName == mono);
|
||||
}
|
||||
|
||||
void TextFontRunsTests::selectedFontSurvivesReopening()
|
||||
{
|
||||
const SavedFonts saved;
|
||||
EditorSession reopened;
|
||||
reopened.installProject(ProjectStore::load(saved.path), saved.path);
|
||||
QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontRuns.value(), (std::vector<LayerTextFontRun>{{0, 2, mono}}));
|
||||
QCOMPARE(reopened.document().value().layers.back().liveText().value().style.fontName, sans);
|
||||
QCOMPARE(ProjectStore::load(saved.path).manifest.version, qint64(11));
|
||||
}
|
||||
|
||||
void TextFontRunsTests::runsNeedVersionElevenAndAValidFace()
|
||||
{
|
||||
const SavedFonts saved;
|
||||
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, mono)}), std::optional<ProjectError::Kind>());
|
||||
// Version 10 cannot hold faces.
|
||||
QCOMPARE(saved.load(10, QJsonArray{run(0, 2, mono)}), std::optional(ProjectError::Kind::invalid));
|
||||
// Overlapping, empty, past the end, nameless, newline, long, none.
|
||||
for (const QJsonArray &runs : {QJsonArray{run(0, 2, mono), run(1, 2, mono)}, QJsonArray{run(0, 0, mono)}, QJsonArray{run(4, 2, mono)},
|
||||
QJsonArray{run(0, 2, QString())}, QJsonArray{run(0, 2, QStringLiteral("A
B"))},
|
||||
QJsonArray{run(0, 2, QString(201, u'x'))}, QJsonArray{}})
|
||||
QCOMPARE(saved.load(11, runs), std::optional(ProjectError::Kind::invalid));
|
||||
// Two hundred characters, a decomposed letter counted once, pass.
|
||||
QString name;
|
||||
for (int index = 0; index < 200; ++index)
|
||||
name += QStringLiteral("é");
|
||||
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, name)}), std::optional<ProjectError::Kind>());
|
||||
// A negative start, a fractional place or length.
|
||||
QCOMPARE(saved.load(11, QJsonArray{run(-1, 2, mono)}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0.5}, {"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2.5}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
|
||||
// Every newline character, and a run past Int's end.
|
||||
for (const QChar newline : {QChar(u'\n'), QChar(u'\r'), QChar(u'\v'), QChar(u'\f'), QChar(0x85), QChar(QChar::LineSeparator), QChar(QChar::ParagraphSeparator)})
|
||||
QCOMPARE(saved.load(11, QJsonArray{run(0, 2, QStringLiteral("A") + newline + QStringLiteral("B"))}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonArray{run(std::numeric_limits<qint64>::max(), 1, mono)}), std::optional(ProjectError::Kind::invalid));
|
||||
// A run missing a key, or no array, fails.
|
||||
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"length", 2}}}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"length", 2}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonArray{QJsonObject{{"location", 0}, {"fontName", mono}}}), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(saved.load(11, QJsonValue(3)), std::optional(ProjectError::Kind::invalid));
|
||||
}
|
||||
|
||||
void TextFontRunsTests::eachRunLaysOutInItsFace()
|
||||
{
|
||||
LayerTextStyle style = hello();
|
||||
style.content = QStringLiteral("iiii\niiii");
|
||||
const double plain = TextLines(style, {10'000, 10'000}).usedSize().width();
|
||||
// A run on the second paragraph widens that line alone.
|
||||
style.setFont(mono, {5, 4});
|
||||
const double wider = TextLines(style, {10'000, 10'000}).usedSize().width();
|
||||
QCOMPARE(wider, 4 * QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i')));
|
||||
QVERIFY(wider > plain);
|
||||
// A face named by PostScript keeps its style: bold.
|
||||
const QString bold = QStringLiteral("DejaVuSans-Bold");
|
||||
LayerTextStyle heavy = hello();
|
||||
heavy.content = QStringLiteral("iiiii");
|
||||
heavy.setFont(bold, {0, 4});
|
||||
QCOMPARE(QFontInfo(TextLayout::font(heavy, bold)).styleName(), QStringLiteral("Bold"));
|
||||
// An independent reference: DejaVu Sans Bold at 72 pixels.
|
||||
QFont reference(QStringLiteral("DejaVu Sans"));
|
||||
reference.setStyleName(QStringLiteral("Bold"));
|
||||
reference.setPixelSize(72);
|
||||
reference.setKerning(false);
|
||||
reference.setHintingPreference(QFont::PreferNoHinting);
|
||||
QFont regular = reference;
|
||||
regular.setStyleName(QStringLiteral("Book"));
|
||||
QCOMPARE(TextLines(heavy, {10'000, 10'000}).usedSize().width(),
|
||||
4 * QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) + QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
|
||||
QVERIFY(QFontMetricsF(reference).horizontalAdvance(QLatin1Char('i')) > QFontMetricsF(regular).horizontalAdvance(QLatin1Char('i')));
|
||||
// A run across a break covers both lines.
|
||||
style.fontRuns = std::nullopt;
|
||||
style.setFont(mono, {2, 5});
|
||||
const TextLines across(style, {10'000, 10'000});
|
||||
const double narrow = QFontMetricsF(TextLayout::font(style, sans)).horizontalAdvance(QLatin1Char('i'));
|
||||
const double wide = QFontMetricsF(TextLayout::font(style, mono)).horizontalAdvance(QLatin1Char('i'));
|
||||
QCOMPARE(across.caret(4).value().left(), 2 * narrow + 2 * wide);
|
||||
QCOMPARE(across.caret(9).value().left(), 2 * wide + 2 * narrow);
|
||||
// At another size and tracking, against fonts built here.
|
||||
LayerTextStyle sized = hello();
|
||||
sized.content = QStringLiteral("iiiii");
|
||||
sized.fontSize = 40;
|
||||
sized.tracking = 3;
|
||||
sized.setFont(mono, {1, 2});
|
||||
const auto made = [](const QString &family) {
|
||||
QFont font(family);
|
||||
font.setPixelSize(40);
|
||||
font.setKerning(false);
|
||||
font.setLetterSpacing(QFont::AbsoluteSpacing, 3);
|
||||
font.setHintingPreference(QFont::PreferNoHinting);
|
||||
return QFontMetricsF(font).horizontalAdvance(QStringLiteral("i"));
|
||||
};
|
||||
const double expected = 3 * made(QStringLiteral("DejaVu Sans")) + 2 * made(QStringLiteral("DejaVu Sans Mono"));
|
||||
QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
|
||||
// Colouring the run's letters keeps their face.
|
||||
sized.setColor(PaletteColor{1, 0, 0}, {1, 2});
|
||||
QVERIFY(sized.colorRuns && sized.fontRuns);
|
||||
QVERIFY(qAbs(TextLines(sized, {10'000, 10'000}).usedSize().width() - expected) < 0.01);
|
||||
// The face keeps the style's size and spacing.
|
||||
style.tracking = 3;
|
||||
QCOMPARE(TextLayout::font(style, mono).letterSpacing(), 3.0);
|
||||
QCOMPARE(TextLayout::font(style, mono).pixelSize(), 72);
|
||||
QCOMPARE(TextLayout::font(style, mono).family(), QStringLiteral("DejaVu Sans Mono"));
|
||||
// An unlisted face asks Qt by its own name.
|
||||
QCOMPARE(TextLayout::font(style, QStringLiteral("Zapfino")).family(), QStringLiteral("Zapfino"));
|
||||
}
|
||||
|
||||
void TextFontRunsTests::newTextStartsInOneFace()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
TextDraft draft = session->textDraft().value();
|
||||
draft.style.content = QStringLiteral("Hello");
|
||||
draft.style.fontName = sans;
|
||||
session->setTextDraft(draft);
|
||||
session->changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
|
||||
QVERIFY(session->finishText());
|
||||
session->beginText(QPointF(300, 300), true);
|
||||
QVERIFY(!session->textDraft().value().style.fontRuns);
|
||||
QCOMPARE(session->textDraft().value().style.fontName, sans);
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextFontRunsTests)
|
||||
#include "TextFontRunsTests.moc"
|
||||
Reference in new issue
Block a user