Clicked text on its baseline, live text on the Move tool's double click, SVG import (12.11c)

This commit is contained in:
雷电芽衣 committed 2026-09-29 10:55:10 -04:00
1 parent 008493b0a3
commit 8fbb104c48
34 files changed
+572 -47

No files matched your search

+1 -1
View File
@@ -323,7 +323,7 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
QTest::qWait(50);
QCOMPARE(int(session.document().value().layers.size()), 2);
desk.typeBeforeCancel.clear();
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb " + RawImporter::globs().join(" ") + ")";
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd *.psb *.svg *.svgz " + RawImporter::globs().join(" ") + ")";
QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel}));
QCOMPARE(desk.seen.size(), 3);
// The flag cleared from elsewhere takes the panel down.
+7 -1
View File
@@ -2,6 +2,7 @@
#include <QProcess>
#include <QTemporaryDir>
#include <QXmlStreamReader>
#include <QMimeDatabase>
#include <QtTest>
// The desktop entry, the project type, and an install.
@@ -70,7 +71,12 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes()
QCOMPARE(keys.value("Terminal"), QString("false"));
QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics"));
// Swift's two document types: its projects, then four pictures.
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/x-dcraw;"));
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/svg+xml;image/svg+xml-compressed;image/x-dcraw;"));
// Each SVG suffix's type as the system names it.
for (const char *name : {"icon.svg", "icon.svgz"}) {
const QString type = QMimeDatabase().mimeTypeForFile(QString::fromLatin1(name), QMimeDatabase::MatchExtension).name();
QVERIFY2(keys.value("MimeType").split(';').contains(type), qPrintable(type));
}
}
void DesktopIntegrationTests::aProjectFolderIsAType()
+10 -10
View File
@@ -102,7 +102,7 @@ void InlineTextDrawTests::theCaretShowsWhileTheCanvasHasFocus()
field->show();
shown.canvas->setFocus();
QTRY_VERIFY(shown.canvas->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("ab"));
const QRectF caret = shown.editor().textTransform().mapRect(
QRectF(QPointF(LayerTextStyle::padding, LayerTextStyle::padding), QSizeF(1, shown.session.textDraft().value().style.lineHeight()))
@@ -149,7 +149,7 @@ void InlineTextDrawTests::aSelectionShowsBehindItsText()
DrawnText shown;
shown.canvas->setFocus();
QTRY_VERIFY(shown.canvas->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("iiii"));
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
// Above the letters, the line's height is highlighted.
@@ -204,13 +204,13 @@ void InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField()
button->show();
field->setFocus();
QTRY_VERIFY(field->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::qWait(50);
QVERIFY(field->hasFocus());
shown.session.cancelText();
button->setFocus();
QTRY_VERIFY(button->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTRY_VERIFY(shown.canvas->hasFocus());
// A focus request leaves open text's keys alone.
button->setFocus();
@@ -235,7 +235,7 @@ void InlineTextDrawTests::theDraftTakesItsKeysAheadOfShortcuts()
QTRY_VERIFY(shown.canvas->hasFocus());
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
QCOMPARE(triggered, 1);
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("all"));
QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
QCOMPARE(triggered, 1);
@@ -281,7 +281,7 @@ void InlineTextDrawTests::theEditMenuReachesOpenText()
EditorSession &session = bar.session();
session.createDocument(400, 300, true);
session.selectTool(NavigationTool::type);
session.beginText(QPointF(20, 30), true);
beginTextAt(session, QPointF(20, 30));
auto *canvas = bar.window.findChild<CanvasView *>();
QTRY_VERIFY(canvas->hasFocus());
QTest::keyClicks(canvas, QStringLiteral("menu"));
@@ -316,7 +316,7 @@ void InlineTextDrawTests::editsRepaintTheEditor()
field->show();
field->setFocus();
QTRY_VERIFY(field->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::qWait(50);
PaintSpy spy(*shown.canvas);
QTest::keyClicks(shown.canvas, QStringLiteral("Wide words"));
@@ -327,7 +327,7 @@ void InlineTextDrawTests::editsRepaintTheEditor()
shown.session.cancelText();
QTRY_VERIFY(spy.painted.contains(stood));
// A paste grows the box past its old margins.
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::qWait(50);
QVERIFY(!shown.canvas->findChild<QTimer *>(QStringLiteral("caretBlink"))->isActive());
spy.painted = QRect();
@@ -361,7 +361,7 @@ void InlineTextDrawTests::editsRepaintTheEditor()
QTRY_VERIFY(spy.painted.contains(right));
// The caret's line after a final newline falls far below.
shown.session.cancelText();
shown.session.beginText(QPointF(20, 20), true);
beginTextAt(shown.session, QPointF(20, 20));
shown.session.changeTextStyle([](LayerTextStyle &style) {
style.fontSize = 24;
style.leading = 120;
@@ -378,7 +378,7 @@ void InlineTextDrawTests::editsRepaintTheEditor()
void InlineTextDrawTests::openingTextRepaintsItsWholeLayer()
{
DrawnText shown;
shown.session.beginText(QPointF(20, 20), true);
beginTextAt(shown.session, QPointF(20, 20));
shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 24; });
QTest::keyClicks(shown.canvas, QStringLiteral("Tall"));
QVERIFY(shown.session.finishText());
+8 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "SelectionCanvasFixtures.h"
#include "Rendering/TextLayout.h"
#include <QInputMethodEvent>
// Shared by the inline editor's key and input tests.
@@ -11,7 +12,7 @@ struct TextCanvas : Canvas {
QObject::connect(&session, &EditorSession::changed, canvas, [this] { canvas->synchronizeDisplay(); });
session.addBlankLayer();
session.selectTool(NavigationTool::type);
session.beginText(QPointF(20, 30), true);
beginTextAt(session, QPointF(20, 30));
}
InlineTextEditor &editor() const
{
@@ -20,6 +21,12 @@ struct TextCanvas : Canvas {
return *canvas->inlineTextEditor();
}
QString content() const { return session.textDraft().value().style.content; }
// Where a click puts new text's corner: baseline at it.
QPointF clickedCorner(QPointF point) const
{
const double padding = LayerTextStyle::padding;
return point - QPointF(padding, padding + TextLines(session.textDraft().value().style, QSizeF(100'000, 100'000)).baseline(0));
}
int caret() const { return editor().caretPosition(); }
void key(Qt::Key key, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::keyClick(canvas, key, modifiers); }
void type(const QString &text) { QTest::keyClicks(canvas, text); }
+3 -3
View File
@@ -47,7 +47,7 @@ void InlineTextPointerTests::aClickOrADragBeginsText()
shown.session.cancelText();
// A click is Swift's small box: point text there.
shown.click(QPointF(50, 60));
QCOMPARE(shown.session.textDraft().value().origin, QPointF(50, 60));
QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(50, 60)));
QVERIFY(!shown.session.textDraft().value().style.boxSize);
shown.session.cancelText();
// A drag draws a box, shown in the accent meanwhile.
@@ -112,7 +112,7 @@ void InlineTextPointerTests::aClickOpensTheTextUnderIt()
const size_t count = shown.session.document().value().layers.size();
shown.click(QPointF(300, 250));
QCOMPARE(shown.session.document().value().layers.size(), count + 1);
QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250));
QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(300, 250)));
shown.session.cancelText();
// A click opens text, active or not, the caret there.
const QUuid kept = shown.session.document().value().layers.back().id;
@@ -287,7 +287,7 @@ void InlineTextPointerTests::aLostReleaseDropsTheBox()
shown.press(QPointF(300, 250));
QTRY_VERIFY(press.painted.contains(QPoint(40, 80)));
shown.release(QPointF(300, 250));
QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250));
QCOMPARE(shown.session.textDraft().value().origin, shown.clickedCorner(QPointF(300, 250)));
QVERIFY(!shown.session.textDraft().value().style.boxSize);
shown.session.cancelText();
// So does a lost focus.
+6 -6
View File
@@ -11,7 +11,7 @@ private slots:
void InlineTextPreeditTests::markedTextIsUnderlined()
{
DrawnText shown;
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QInputMethodEvent composing(QStringLiteral("nn"), {});
QApplication::sendEvent(shown.canvas, &composing);
// Below the baseline, where no letter of these reaches.
@@ -48,7 +48,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
DrawnText shown;
shown.canvas->setFocus();
QTRY_VERIFY(shown.canvas->hasFocus());
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
// Its clause's format replaces the underline; its caret hides.
QTextCharFormat clause;
clause.setBackground(Qt::yellow);
@@ -63,14 +63,14 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
QCOMPARE(shown.grab().pixelColor(above), QColor(Qt::yellow));
// Formats count from the preedit, wherever it sits.
shown.session.cancelText();
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("nn"));
QApplication::sendEvent(shown.canvas, &composing);
QVERIFY(shown.grab().pixelColor(above) != QColor(Qt::yellow));
const QPoint later = shown.editor().textTransform().map(QPointF(padding + width * 1.5, baseline - metrics.xHeight() - 4)).toPoint();
QCOMPARE(shown.grab().pixelColor(later), QColor(Qt::yellow));
shown.session.cancelText();
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QApplication::sendEvent(shown.canvas, &composing);
const QRect band = shown.editor().textTransform().mapRect(QRectF(padding, baseline + 1, width - 4, metrics.descent() - 2)).toAlignedRect();
QVERIFY(!where(shown.grab().copy(band), reddish).isValid());
@@ -85,7 +85,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
QVERIFY(where(shown.grab().copy(band), reddish).isValid());
// The input method's selection shows beside its preedit.
shown.session.cancelText();
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("xx"));
QInputMethodEvent selecting(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 2, QVariant())});
QApplication::sendEvent(shown.canvas, &selecting);
@@ -95,7 +95,7 @@ void InlineTextPreeditTests::theInputMethodStylesItsPreedit()
QCOMPARE(shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight));
// Past the preedit, it sits after the preedit's letters.
shown.session.cancelText();
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("xx"));
QInputMethodEvent ahead(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 2, -2, QVariant())});
QApplication::sendEvent(shown.canvas, &ahead);
+188
View File
@@ -0,0 +1,188 @@
#include "SelectionCanvasFixtures.h"
#include "Rendering/TextLayout.h"
#include <QFontMetricsF>
// Swift 1.2.10 (a5721b2): the Move tool's double click opens text.
namespace {
// Live text whose box covers `box`, made in one step.
QUuid addText(EditorSession &session, const QString &content, QRectF box)
{
session.selectTool(NavigationTool::type);
session.beginText(box);
TextDraft draft = session.textDraft().value();
draft.style.content = content;
if (!session.applyText(draft))
throw std::runtime_error("the text was refused");
return session.activeLayerID().value();
}
// Opaque red pixels, as a layer's.
QImage redPixels(int width, int height)
{
QImage image = BrushRaster::context(width, height, false);
image.fill(Qt::red);
return image;
}
// A document point on the canvas's view.
QPointF viewPoint(Canvas &shown, QPointF pixel)
{
return shown.session.viewport.viewPoint(pixel, shown.documentSize());
}
}
class LiveTextDoubleClickTests : public QObject {
Q_OBJECT
private slots:
void theTopmostShownTextOpens();
void otherPressesKeepTheMoveTool();
void anOpenDragAndPreviewEndFirst();
void theCaretWaitsAfterTheText();
void zoomAndTurnsPlaceThePress();
};
void LiveTextDoubleClickTests::theTopmostShownTextOpens()
{
// No display sync: the gesture opens the editor itself.
Canvas shown;
const QUuid lower = addText(shown.session, QStringLiteral("Lower"), QRectF(20, 20, 200, 100));
const QUuid upper = addText(shown.session, QStringLiteral("Upper"), QRectF(100, 60, 200, 100));
shown.session.addBlankLayer();
const QUuid blank = shown.session.activeLayerID().value();
shown.session.selectTool(NavigationTool::move);
// Where both lie, the upper opens, in the Type tool.
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint());
QCOMPARE(shown.session.tool(), NavigationTool::type);
QCOMPARE(shown.session.activeLayerID(), std::optional(upper));
QCOMPARE(shown.session.textDraft().value().layerID, std::optional(upper));
QVERIFY(shown.canvas->inlineTextEditor());
shown.session.cancelText();
// A hidden text is passed over for the one below.
shown.session.selectTool(NavigationTool::move);
shown.session.toggleLayerVisibility(upper);
shown.session.selectLayer(blank);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint());
QCOMPARE(shown.session.textDraft().value().layerID, std::optional(lower));
QCOMPARE(shown.session.activeLayerID(), std::optional(lower));
shown.session.cancelText();
// A text in a hidden folder is passed over too.
shown.session.selectTool(NavigationTool::move);
shown.session.toggleLayerVisibility(upper);
shown.session.selectLayer(upper);
shown.session.groupSelectedLayers();
const QUuid folder = shown.session.activeLayerID().value();
QCOMPARE(layerWith(shown.session, upper).parentID, std::optional(folder));
shown.session.toggleLayerVisibility(folder);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(150, 90)).toPoint());
QCOMPARE(shown.session.textDraft().value().layerID, std::optional(lower));
}
void LiveTextDoubleClickTests::anOpenDragAndPreviewEndFirst()
{
// On the text already active, Swift's commitTransform still cleans up.
Canvas shown;
const QUuid text = addText(shown.session, QStringLiteral("Words"), QRectF(20, 20, 200, 100));
shown.session.selectTool(NavigationTool::move);
shown.session.beginOpacityEdit();
shown.session.setLayerOpacity(0.5);
shown.session.previewBlendMode(LayerBlendMode::multiply, text);
QVERIFY(!shown.session.canUndo());
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint());
QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text));
QVERIFY(shown.session.displayedBlendMode(layerWith(shown.session, text)) == LayerBlendMode::normal);
shown.session.cancelText();
QVERIFY(shown.session.canUndo());
}
void LiveTextDoubleClickTests::otherPressesKeepTheMoveTool()
{
Canvas shown;
QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); });
const QUuid text = addText(shown.session, QStringLiteral("Words"), QRectF(20, 20, 200, 100));
shown.session.selectTool(NavigationTool::move);
// One click moves; it opens nothing.
shown.click(viewPoint(shown, QPointF(60, 60)));
QVERIFY(!shown.session.textDraft());
QCOMPARE(shown.session.tool(), NavigationTool::move);
// Off the text, a double click is the Move tool's.
shown.session.addPixelLayer(redPixels(60, 40), QPointF(260, 220), QStringLiteral("Red"), QStringLiteral("Add"));
const QUuid red = shown.session.activeLayerID().value();
shown.session.selectLayer(text);
shown.session.setTransformAutoSelect(true);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(280, 240)).toPoint());
QVERIFY(!shown.session.textDraft());
QCOMPARE(shown.session.tool(), NavigationTool::move);
QCOMPARE(shown.session.activeLayerID(), std::optional(red));
// While a layer is renamed, layers rest: nothing opens.
shown.session.setRenamingLayerID(red);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint());
QVERIFY(!shown.session.textDraft());
QCOMPARE(shown.session.activeLayerID(), std::optional(red));
shown.session.setRenamingLayerID(std::nullopt);
// A busy project opens nothing.
shown.session.setIsProjectBusy(true);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint());
QVERIFY(!shown.session.textDraft());
shown.session.setIsProjectBusy(false);
// Other tools' double clicks are theirs.
shown.session.selectTool(NavigationTool::marquee);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint());
QVERIFY(!shown.session.textDraft());
QCOMPARE(shown.session.activeLayerID(), std::optional(red));
}
void LiveTextDoubleClickTests::theCaretWaitsAfterTheText()
{
Canvas shown;
QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); });
addText(shown.session, QStringLiteral("Word"), QRectF(20, 20, 300, 150));
shown.session.selectTool(NavigationTool::move);
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(60, 60)).toPoint());
QTRY_COMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 4);
QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 4);
QTest::keyClicks(shown.canvas, QStringLiteral("X"));
QCOMPARE(shown.session.textDraft().value().style.content, QString("WordX"));
// A caret a click placed stays where it was put.
shown.session.cancelText();
shown.session.selectTool(NavigationTool::type);
const LayerTextStyle style = layerWith(shown.session, shown.session.activeLayerID().value()).liveText().value().style;
const QFontMetricsF metrics(TextLayout::font(style));
const double padding = LayerTextStyle::padding;
const QPointF at = viewPoint(shown, QPointF(20 + padding + metrics.horizontalAdvance(QStringLiteral("Wo")), 20 + padding + style.lineHeight() / 2));
shown.click(at);
QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 2);
QCoreApplication::processEvents();
QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 2);
QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 2);
// New text keeps its caret, despite opened text's queued step.
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};
fresh.style.content = QStringLiteral("New");
shown.session.setTextDraft(fresh);
QCoreApplication::processEvents();
QCOMPARE(shown.session.textDraft().value().id, fresh.id);
QCOMPARE(shown.canvas->inlineTextEditor()->caretPosition(), 0);
QCOMPARE(shown.canvas->inlineTextEditor()->anchor(), 0);
}
void LiveTextDoubleClickTests::zoomAndTurnsPlaceThePress()
{
Canvas shown;
const QUuid text = addText(shown.session, QStringLiteral("Turned"), QRectF(100, 100, 160, 60));
rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, text).transform.rotation = 90; });
shown.session.selectTool(NavigationTool::move);
shown.session.zoom(1.5);
QVERIFY(shown.session.viewport.viewPoint(QPointF(180, 70), shown.documentSize()) != QPointF(180, 70));
// Outside the turned box, inside the upright one: nothing.
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(110, 130)).toPoint());
QVERIFY(!shown.session.textDraft());
shown.release(viewPoint(shown, QPointF(110, 130)));
// Inside the turned box only: the text opens.
QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, viewPoint(shown, QPointF(180, 70)).toPoint());
QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text));
}
QTEST_MAIN(LiveTextDoubleClickTests)
#include "LiveTextDoubleClickTests.moc"
+1 -1
View File
@@ -223,7 +223,7 @@ void PickingTests::theInlineEditorStandsAsideForThePicker()
Picking shown;
QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); });
shown.tool(NavigationTool::type);
shown.session.beginText(QPointF(20, 30), true);
beginTextAt(shown.session, QPointF(20, 30));
QTest::keyClicks(shown.canvas, QStringLiteral("Hello there"));
QTest::keyClick(shown.canvas, Qt::Key_Home);
const InlineTextEditor &editor = *shown.canvas->inlineTextEditor();
+174
View File
@@ -0,0 +1,174 @@
#include "BudgetFixtures.h"
#include "Document/EditorSession.h"
#include "IO/ImageImporter.h"
#include "IO/PSD/PSDReader.h"
#include <QColorSpace>
#include <QTemporaryDir>
#include <QtTest>
#include <zlib.h>
// Swift 1.2.10 (0534e00): SVG drawn once into an image layer.
namespace {
// A red rectangle filling a document of the given size.
QByteArray svg(const QByteArray &width, const QByteArray &height)
{
return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" + width + "\" height=\"" + height + "\"><rect width=\"100%\" height=\"100%\" fill=\"red\"/></svg>";
}
QString written(const QTemporaryDir &folder, const QString &name, const QByteArray &bytes)
{
QFile file(folder.filePath(name));
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size())
throw std::runtime_error("cannot write a fixture");
return file.fileName();
}
// The same bytes in gzip, as `.svgz` holds them.
QByteArray gzipped(const QByteArray &bytes)
{
z_stream stream{};
if (deflateInit2(&stream, 9, Z_DEFLATED, 31, 8, Z_DEFAULT_STRATEGY) != Z_OK)
throw std::runtime_error("no zlib");
QByteArray out(int(deflateBound(&stream, uLong(bytes.size()))), '\0');
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(bytes.constData()));
stream.avail_in = uInt(bytes.size());
stream.next_out = reinterpret_cast<Bytef *>(out.data());
stream.avail_out = uInt(out.size());
deflate(&stream, Z_FINISH);
out.resize(qsizetype(stream.total_out));
deflateEnd(&stream);
return out;
}
std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const ImageImportError &error) {
return error.kind;
}
return std::nullopt;
}
bool imported(EditorSession &session, const QList<QUrl> &urls)
{
bool done = false;
session.importImages(urls, std::nullopt, [&] { done = true; });
return QTest::qWaitFor([&] { return done && !session.isImporting(); }, 10'000);
}
}
class SVGImportTests : public QObject {
Q_OBJECT
private slots:
void anSVGFitsTheCanvasOrKeepsItsSize();
void brokenOrOversizedSVGsRefuse();
void theSessionImportsSVGBySuffix();
};
void SVGImportTests::anSVGFitsTheCanvasOrKeepsItsSize()
{
const QTemporaryDir folder;
const QString path = written(folder, QStringLiteral("icon.svg"), svg("40", "20"));
const ImportedImage alone = ImageImporter::decodeSVG(path, std::nullopt);
QCOMPARE(alone.size(), QSize(40, 20));
QCOMPARE(alone.name, QString("icon"));
QCOMPARE(alone.image().pixelColor(20, 10), QColor(255, 0, 0));
QCOMPARE(alone.image().colorSpace(), QColorSpace(QColorSpace::SRgb));
QVERIFY(!alone.thumbnail.isNull());
// Fitted, the smaller side's ratio rules and sides round.
QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(400, 300)).size(), QSize(400, 200));
QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(10, 10)).size(), QSize(10, 5));
const QString thin = written(folder, QStringLiteral("thin.svg"), svg("3", "1"));
QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(10, 10)).size(), QSize(10, 3));
QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(1, 1)).size(), QSize(1, 1));
QCOMPARE(ImageImporter::decodeSVG(thin, QSizeF(5, 5)).size(), QSize(5, 2));
QCOMPARE(ImageImporter::decodeSVG(path, QSizeF(400, 100)).size(), QSize(200, 100));
const QString narrow = written(folder, QStringLiteral("narrow.svg"), svg("1", "3"));
QCOMPARE(ImageImporter::decodeSVG(narrow, QSizeF(1, 1)).size(), QSize(1, 1));
QCOMPARE(ImageImporter::decodeSVG(narrow, QSizeF(5, 5)).size(), QSize(2, 5));
// Red top left, blue bottom right, clear between: upright.
const QString corners = written(folder, QStringLiteral("corners.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"20\"><rect width=\"5\" height=\"10\" fill=\"red\"/>"
"<rect x=\"5\" y=\"10\" width=\"5\" height=\"10\" fill=\"blue\"/></svg>");
const QImage fitted = ImageImporter::decodeSVG(corners, QSizeF(100, 100)).image();
QCOMPARE(fitted.size(), QSize(50, 100));
QCOMPARE(fitted.pixelColor(12, 25), QColor(255, 0, 0));
QCOMPARE(fitted.pixelColor(37, 75), QColor(0, 0, 255));
QCOMPARE(fitted.pixelColor(37, 25).alpha(), 0);
QCOMPARE(fitted.pixelColor(12, 75).alpha(), 0);
// A large drawing's thumbnail is 96 on its long side.
const QString large = written(folder, QStringLiteral("large.svg"), svg("200", "400"));
QCOMPARE(ImageImporter::decodeSVG(large, std::nullopt).thumbnail.size(), QSize(48, 96));
// Compressed and upper-case names read as SVG.
const QString compressed = written(folder, QStringLiteral("icon.SVGZ"), gzipped(svg("40", "20")));
QVERIFY(ImageImporter::isSVG(compressed));
QCOMPARE(ImageImporter::decodeSVG(compressed, std::nullopt).size(), QSize(40, 20));
QVERIFY(ImageImporter::isSVG(QStringLiteral("/a/b.Svg")));
QVERIFY(!ImageImporter::isSVG(QStringLiteral("/a/b.png")));
QVERIFY(!ImageImporter::isSVG(QStringLiteral("/a/svg")));
}
void SVGImportTests::brokenOrOversizedSVGsRefuse()
{
const QTemporaryDir folder;
using Kind = ImageImportError::Kind;
const QString broken = written(folder, QStringLiteral("broken.svg"), "<svg");
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(broken, std::nullopt); }), std::optional(Kind::unreadable));
// An empty drawing with a zero side has no size.
const QString empty = written(folder, QStringLiteral("empty.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"20\"/>");
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(empty, std::nullopt); }), std::optional(Kind::unreadable));
for (const char *box : {"0 0 0 10", "0 0 10 0"}) {
const QString flat = written(folder, QStringLiteral("flat.svg"), QByteArray("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"") + box + "\"/>");
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(flat, std::nullopt); }), std::optional(Kind::unreadable));
}
const QString wide = written(folder, QStringLiteral("wide.svg"), svg("30001", "1"));
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(wide, std::nullopt); }), std::optional(Kind::tooLarge));
QCOMPARE(ImageImporter::decodeSVG(wide, QSizeF(30'000, 30'000)).size(), QSize(30'000, 1));
const QString tall = written(folder, QStringLiteral("tall.svg"), svg("1", "30001"));
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(tall, std::nullopt); }), std::optional(Kind::tooLarge));
QCOMPARE(ImageImporter::decodeSVG(tall, QSizeF(30'000, 30'000)).size(), QSize(1, 30'000));
// The budget counts the pixels drawn.
const QString icon = written(folder, QStringLiteral("icon.svg"), svg("40", "20"));
QCOMPARE(ImageImporter::decodeSVG(icon, std::nullopt, 800).size(), QSize(40, 20));
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(icon, std::nullopt, 799); }), std::optional(Kind::tooLarge));
// Fitted, the budget counts the drawn size, not the declared.
QCOMPARE(ImageImporter::decodeSVG(icon, QSizeF(400, 300), 80'000).size(), QSize(400, 200));
QCOMPARE(refusal([&] { ImageImporter::decodeSVG(icon, QSizeF(400, 300), 79'999); }), std::optional(Kind::tooLarge));
QCOMPARE(ImageImporter::decodeSVG(icon, QSizeF(10, 10), 50).size(), QSize(10, 5));
}
void SVGImportTests::theSessionImportsSVGBySuffix()
{
const QTemporaryDir folder;
const QString icon = written(folder, QStringLiteral("icon.svg"), svg("40", "20"));
// Without a document, its declared size makes the canvas.
EditorSession session;
QVERIFY(imported(session, {QUrl::fromLocalFile(icon)}));
QCOMPARE(session.document().value().size(), QSizeF(40, 20));
QCOMPARE(session.activeLayer().value().name, QString("icon"));
// Over a canvas, it is drawn to fit it.
EditorSession canvas;
canvas.createDocument(400, 300, true);
QVERIFY(imported(canvas, {QUrl::fromLocalFile(icon)}));
QCOMPARE(canvas.activeLayer().value().asset.value().size(), QSize(400, 200));
QCOMPARE(canvas.activeLayer().value().transform.origin, QPointF(0, 50));
// The suffix rules before Photoshop's bytes.
const QString disguised = written(folder, QStringLiteral("disguised.svg"), QByteArray("8BPS\0\1", 6) + QByteArray(40, '\0'));
QVERIFY(PSDReader::matches(disguised));
QVERIFY(imported(canvas, {QUrl::fromLocalFile(disguised)}));
QCOMPARE(canvas.importError().value(), QString("disguised.svg: The image could not be read. It may be damaged or unavailable."));
// The drawing counts against what the canvas already holds.
EditorSession full;
full.createDocument(40, 20, true);
for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 800))
full.insert(layer);
QVERIFY(imported(full, {QUrl::fromLocalFile(icon)}));
QCOMPARE(full.importError(), std::nullopt);
QCOMPARE(full.activeLayer().value().asset.value().size(), QSize(40, 20));
QVERIFY(imported(full, {QUrl::fromLocalFile(icon)}));
QVERIFY(full.importError().value().startsWith("icon.svg: This import exceeds"));
}
QTEST_MAIN(SVGImportTests)
#include "SVGImportTests.moc"
+9
View File
@@ -58,3 +58,12 @@ inline ImportedImage coverage()
image.scanLine(index / 2)[index % 2] = values[index];
return LayerMask::assetFrom(image);
}
// New text whose box starts at `corner`, unlike a click.
inline void beginTextAt(EditorSession &session, QPointF corner)
{
session.beginText(corner, true);
TextDraft draft = session.textDraft().value();
draft.origin = corner;
session.setTextDraft(draft);
}
+6
View File
@@ -49,6 +49,8 @@ compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(LiveTextDoubleClickTests)
compositor_test(SVGImportTests)
compositor_test(TextLayoutTests)
compositor_test(TypeSessionTests)
compositor_test(TypeStoreTests)
@@ -284,3 +286,7 @@ compositor_test(ByteCountsTests)
compositor_test(ImageFileDropTests)
compositor_test(SliderSnapTests)
compositor_test(ProjectResizeFailureTests)
# The `.svgz` fixture is gzip, written through zlib.
find_package(ZLIB REQUIRED)
target_link_libraries(SVGImportTests PRIVATE ZLIB::ZLIB)
+1 -1
View File
@@ -405,7 +405,7 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText()
QVERIFY(QTest::qWaitForWindowActive(&list));
LayerCell &textRow = *list.cells().at(0), &blankRow = *list.cells().at(1);
const auto dimensions = [](LayerCell &row) { return row.findChild<QLabel *>("layerDimensions")->text(); };
QCOMPARE(dimensions(textRow), QString("Text"));
QCOMPARE(dimensions(textRow), QString("Text · Double-click to edit"));
QCOMPARE(dimensions(blankRow), QString("400 × 300 px"));
QCOMPARE(textRow.thumbnail().toolTip(), QString("Editable text layer"));
QCOMPARE(textRow.thumbnail().accessibleName(), QString("Select text: Row"));
+2 -2
View File
@@ -19,7 +19,7 @@ std::unique_ptr<EditorSession> makeSession()
QUuid textLayer(EditorSession &session, QPointF at, const QString &content)
{
session.selectTool(NavigationTool::type);
session.beginText(at, true);
beginTextAt(session, at);
TextDraft draft = session.textDraft().value();
draft.style.content = content;
if (!session.applyText(draft))
@@ -234,7 +234,7 @@ void TypeSessionTests::aClickFindsTheTextUnderIt()
const TextDraft fresh = session->textDraft().value();
QVERIFY(!fresh.layerID && fresh.style.content.isEmpty() && !fresh.style.boxSize);
QVERIFY(fresh.style.red == 0.25 && fresh.style.green == 1 && fresh.style.blue == 0.5);
QCOMPARE(fresh.origin, QPointF(700, 300));
QVERIFY(fresh.origin.x() == 688 && std::abs(fresh.origin.y() + fresh.style.lineHeight() - 288) < fresh.style.fontSize / 3);
session->cancelText();
session->beginText(QPointF(NAN, 5));
QVERIFY(!session->textDraft());
+50 -1
View File
@@ -2,6 +2,8 @@
#include "SessionFixtures.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Rendering/TextLayout.h"
#include <QFontMetricsF>
#include <QTemporaryDir>
// Swift's TypeToolTests: text layers made, edited, saved, clipped.
@@ -60,6 +62,7 @@ private slots:
void invalidAndStaleDraftsDoNotChangeDocument();
void textColorPickerPreviewsAndRestoresDraft();
void theForegroundPickerPreviewsOpenTextToo();
void clickedTextSitsOnItsBaseline();
};
void TypeToolTests::createEditCancelAndUndo()
@@ -67,6 +70,12 @@ void TypeToolTests::createEditCancelAndUndo()
const auto session = makeSession();
const int before = session->history.undoCount();
session->beginText(QPointF(30, 40));
// The first letter starts at the pointer, on its baseline.
const TextDraft start = session->textDraft().value();
const double descent = QFontMetricsF(TextLayout::font(start.style)).descent();
// The laid line's descent, as drawn, within a hundredth.
QCOMPARE(start.origin.x(), 30 - LayerTextStyle::padding);
QVERIFY(std::abs(start.origin.y() - (40 - (LayerTextStyle::padding + start.style.lineHeight() - descent))) < 0.02);
type(*session, QStringLiteral("Text"));
QCOMPARE(session->document().value().layers.size(), size_t(1));
TextDraft draft = session->textDraft().value();
@@ -74,7 +83,7 @@ void TypeToolTests::createEditCancelAndUndo()
draft.style.fontSize = 48;
QVERIFY(session->applyText(draft));
QCOMPARE(session->activeLayer().value().liveText().value().style, draft.style);
QCOMPARE(session->activeLayer().value().origin(), QPointF(30, 40));
QCOMPARE(session->activeLayer().value().origin(), start.origin);
QCOMPARE(session->history.undoCount(), before + 1);
session->editActiveText();
session->setTextDraft(std::nullopt);
@@ -162,6 +171,10 @@ void TypeToolTests::clippingToTextExportsColoredGlyphsOnTransparency()
{
const auto session = makeSession();
session->beginText(QPointF(0, 0));
// The box on the canvas's corner, where the fill goes.
TextDraft corner = session->textDraft().value();
corner.origin = QPointF(0, 0);
session->setTextDraft(corner);
type(*session, QStringLiteral("Text"));
QVERIFY(session->applyText(session->textDraft().value()));
const QUuid source = session->activeLayerID().value();
@@ -190,6 +203,8 @@ void TypeToolTests::paragraphBoxAndToolSwitchCommitEditableText()
session->selectTool(NavigationTool::brush);
QVERIFY(!session->textDraft() && session->tool() == NavigationTool::brush);
QCOMPARE(session->activeLayer().value().size(), QSizeF(200, 120));
// A dragged box sits exactly where it was drawn.
QCOMPARE(session->activeLayer().value().origin(), QPointF(40, 60));
QCOMPARE(session->activeLayer().value().liveText().value().style.boxSize, std::optional(QSizeF(200, 120)));
session->selectTool(NavigationTool::type);
session->editActiveText();
@@ -317,5 +332,39 @@ void TypeToolTests::theForegroundPickerPreviewsOpenTextToo()
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
}
void TypeToolTests::clickedTextSitsOnItsBaseline()
{
// Ink ends on the clicked row, from its column.
const auto session = makeSession();
// A first text sets the face the next click uses.
session->beginText(QPointF(600, 500));
TextDraft draft = session->textDraft().value();
draft.style.content = QStringLiteral("Face");
draft.style.fontName = QStringLiteral("DejaVuSans");
draft.style.fontSize = 40;
QVERIFY(session->applyText(draft));
session->beginText(QPointF(100, 200));
draft = session->textDraft().value();
draft.style.content = QStringLiteral("HHH");
const LayerTextStyle before = draft.style;
QCOMPARE(before.fontName, QString("DejaVuSans"));
QVERIFY(session->applyText(draft));
const ImageLayer layer = session->activeLayer().value();
QCOMPARE(layer.liveText().value().style, before);
const QImage image = layer.asset.value().image();
int bottom = -1, left = image.width();
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (qAlpha(image.pixel(x, y)) > 128) {
bottom = y;
left = std::min(left, x);
}
}
}
const QPointF origin = layer.origin();
QVERIFY2(std::abs(origin.y() + bottom + 1 - 200) < 1, qPrintable(QString::number(origin.y() + bottom + 1)));
QVERIFY2(std::abs(origin.x() + left - 100) < 5, qPrintable(QString::number(origin.x() + left)));
}
QTEST_MAIN(TypeToolTests)
#include "TypeToolTests.moc"