Files
OmaPhoto/tests/InlineTextEditorTests.cpp
T
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-26 00:44:52 -04:00

462 lines
19 KiB
C++

#include "InlineTextFixtures.h"
// Typing on the canvas: keys, moves, undo, deletions, spacing.
class InlineTextEditorTests : public QObject {
Q_OBJECT
private slots:
void aDraftOpensTheEditorAndItsTextGrows();
void typingIsOneUndoStep();
void anotherDraftStartsAfresh();
void arrowsHomeAndEndMoveAndShiftSelects();
void upAndDownKeepTheirColumn();
void deletionsReachCharactersWordsAndLines();
void aWrappedParagraphMovesByItsLines();
void aCaretPastTheBoxHasNoLine();
void aFinalNewlineHasALineForTheCaret();
void arrowsFollowARightToLeftParagraph();
void returnTabEscapeAndFinishing();
void altWithTheArrowsSetsSpacing();
};
void InlineTextEditorTests::aDraftOpensTheEditorAndItsTextGrows()
{
TextCanvas shown;
InlineTextEditor &editor = shown.editor();
QCOMPARE(editor.draftID(), shown.session.textDraft().value().id);
QCOMPARE(editor.logicalSize(), EditorSession::textBoxSize(shown.session.textDraft().value().style));
QCOMPARE(editor.shownTransform(), (LayerTransform{.origin = {20, 30}, .size = editor.logicalSize()}));
const double empty = editor.logicalSize().width();
shown.type(QStringLiteral("Grow"));
QCOMPARE(shown.content(), QString("Grow"));
QCOMPARE(editor.logicalSize(), EditorSession::textBoxSize(shown.session.textDraft().value().style));
QVERIFY(editor.logicalSize().width() > empty);
// Six view points a handle at any zoom.
QCOMPARE(editor.handleSize(), 6.0);
shown.session.zoom(2);
QCOMPARE(editor.handleSize(), 3.0);
shown.session.zoom(4);
QCOMPARE(editor.handleSize(), 2.0);
shown.session.zoom(0.005);
QCOMPARE(editor.handleSize(), 600.0);
shown.session.zoom(1);
// Content set elsewhere pulls the caret back inside.
TextDraft shorter = shown.session.textDraft().value();
shorter.style.content = QStringLiteral("Gr");
shown.session.setTextDraft(shorter);
QCOMPARE(editor.caretPosition(), 2);
QCOMPARE(editor.anchor(), 2);
// Point text on a scaled layer grows at that scale.
QVERIFY(shown.session.finishText());
const QUuid id = shown.session.activeLayerID().value();
const QSize pixels = layerWith(shown.session, id).asset.value().size();
rewrite(shown.session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).transform = LayerTransform{.origin = {50, 40}, .size = QSizeF(pixels) * 2, .rotation = 30};
});
shown.session.editActiveText();
InlineTextEditor &scaled = shown.editor();
const QPointF corner = scaled.shownTransform().point(QPointF(0, 0));
shown.key(Qt::Key_End);
shown.type(QStringLiteral(" more"));
QCOMPARE(scaled.shownTransform().size, scaled.logicalSize() * 2);
QVERIFY((scaled.shownTransform().point(QPointF(0, 0)) - corner).manhattanLength() < 1e-9);
QCOMPARE(scaled.shownTransform().rotation, 30.0);
// Handles stay six points across a layer twice the size.
shown.session.zoom(1);
QCOMPARE(scaled.handleSize(), 3.0);
// A box keeps its size however much is typed.
shown.session.cancelText();
shown.session.beginText(QRectF(10, 10, 200, 100));
shown.type(QStringLiteral("Boxed words that go on"));
QCOMPARE(shown.editor().logicalSize(), QSizeF(200, 100));
// A box shows the transform its draft holds, never grown.
QVERIFY(shown.session.finishText());
shown.session.editActiveText();
TextDraft boxed = shown.session.textDraft().value();
boxed.style.boxSize = QSizeF(300, 100);
boxed.transform = LayerTransform{.origin = {10, 10}, .size = {300, 100}};
shown.session.setTextDraft(boxed);
QCOMPARE(shown.editor().shownTransform(), boxed.transform.value());
}
void InlineTextEditorTests::typingIsOneUndoStep()
{
TextCanvas shown;
shown.type(QStringLiteral("Hi you"));
QCOMPARE(shown.editor().caretPosition(), 6);
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString());
shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.content(), QString("Hi you"));
QCOMPARE(shown.editor().caretPosition(), 6);
// A move ends the step; deleting starts another.
shown.key(Qt::Key_Left);
shown.type(QStringLiteral("!"));
shown.key(Qt::Key_Backspace);
shown.key(Qt::Key_Backspace);
QCOMPARE(shown.content(), QString("Hi yu"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("Hi yo!u"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("Hi you"));
// Typing drops the redo; a move ends the step.
shown.key(Qt::Key_End);
shown.type(QStringLiteral("r"));
shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.content(), QString("Hi your"));
shown.key(Qt::Key_Left);
shown.type(QStringLiteral("s"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("Hi your"));
// A fresh draft starts with nothing to undo or redo.
shown.key(Qt::Key_Z, Qt::ControlModifier);
shown.session.cancelText();
shown.session.beginText(QPointF(100, 100), true);
shown.key(Qt::Key_Z, Qt::ControlModifier);
shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.content(), QString());
QVERIFY(shown.session.textDraft());
shown.type(QStringLiteral("x"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString());
}
void InlineTextEditorTests::anotherDraftStartsAfresh()
{
TextCanvas shown;
// A draft set over this drops its steps, as Swift's.
shown.type(QStringLiteral("ab"));
TextDraft other = shown.session.textDraft().value();
other.id = QUuid::createUuid();
other.style.content = QStringLiteral("new");
shown.session.setTextDraft(other);
QCOMPARE(shown.editor().draftID(), other.id);
shown.type(QStringLiteral("s"));
QCOMPARE(shown.content(), QString("nesw"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("new"));
other.id = QUuid::createUuid();
shown.session.setTextDraft(other);
shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.content(), QString("new"));
// A composition in progress ends with its draft.
shown.compose(QStringLiteral("ni"));
other.id = QUuid::createUuid();
other.style.content = QStringLiteral("NEW");
shown.session.setTextDraft(other);
QVERIFY(!shown.editor().marked());
shown.commit(QStringLiteral("X"));
QCOMPARE(shown.content(), QString("NEWX"));
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("NEW"));
}
void InlineTextEditorTests::arrowsHomeAndEndMoveAndShiftSelects()
{
TextCanvas shown;
shown.type(QStringLiteral("one two"));
shown.key(Qt::Key_Return);
shown.type(QStringLiteral("three"));
shown.key(Qt::Key_Home);
QCOMPARE(shown.editor().caretPosition(), 8);
shown.key(Qt::Key_Up);
QCOMPARE(shown.editor().caretPosition(), 0);
shown.key(Qt::Key_Right, Qt::ControlModifier);
QCOMPARE(shown.editor().caretPosition(), 3);
shown.key(Qt::Key_Right, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.selection(), (TextRange{3, 7}));
// A plain arrow collapses a selection to its side.
shown.key(Qt::Key_Left);
QCOMPARE(shown.selection(), (TextRange{3, 3}));
shown.key(Qt::Key_End, Qt::ShiftModifier);
QCOMPARE(shown.selection(), (TextRange{3, 7}));
shown.key(Qt::Key_Right);
QCOMPARE(shown.editor().caretPosition(), 7);
shown.key(Qt::Key_Left, Qt::ControlModifier);
QCOMPARE(shown.editor().caretPosition(), 4);
// Down keeps its first x; below the last, the end.
shown.key(Qt::Key_Down);
QVERIFY(shown.editor().caretPosition() > 8 && shown.editor().caretPosition() < 13);
shown.key(Qt::Key_Down);
QCOMPARE(shown.editor().caretPosition(), 13);
shown.key(Qt::Key_Home, Qt::ControlModifier);
QCOMPARE(shown.editor().caretPosition(), 0);
shown.key(Qt::Key_End, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.selection(), (TextRange{0, 13}));
// A character a step; above the first line, its start.
shown.key(Qt::Key_Home, Qt::ControlModifier);
shown.key(Qt::Key_Right);
shown.key(Qt::Key_Right);
shown.key(Qt::Key_Left);
QCOMPARE(shown.caret(), 1);
shown.key(Qt::Key_Up);
QCOMPARE(shown.caret(), 0);
// Nothing lies past either end.
shown.key(Qt::Key_Left);
QCOMPARE(shown.caret(), 0);
shown.key(Qt::Key_End, Qt::ControlModifier);
shown.key(Qt::Key_Right);
QCOMPARE(shown.caret(), 13);
}
void InlineTextEditorTests::upAndDownKeepTheirColumn()
{
TextCanvas shown;
shown.type(QStringLiteral("abcdef"));
shown.key(Qt::Key_Return);
shown.type(QStringLiteral("ab"));
shown.key(Qt::Key_Return);
shown.type(QStringLiteral("abcdef"));
shown.key(Qt::Key_Home, Qt::ControlModifier);
shown.key(Qt::Key_End);
// Through a short line and back, as NSTextView.
shown.key(Qt::Key_Down);
QCOMPARE(shown.caret(), 9);
shown.key(Qt::Key_Down);
QCOMPARE(shown.caret(), 16);
shown.key(Qt::Key_Up);
shown.key(Qt::Key_Up);
QCOMPARE(shown.caret(), 6);
// An edit starts the column afresh.
shown.key(Qt::Key_Down);
shown.key(Qt::Key_Backspace);
shown.key(Qt::Key_Down);
QCOMPARE(shown.caret(), 10);
}
void InlineTextEditorTests::aWrappedParagraphMovesByItsLines()
{
TextCanvas shown;
shown.session.cancelText();
shown.session.beginText(QRectF(10, 10, 300, 400));
shown.type(QStringLiteral("alpha beta gamma"));
// One word a line: Home keeps to its line.
shown.key(Qt::Key_Home);
QCOMPARE(shown.editor().caretPosition(), 11);
shown.key(Qt::Key_Up);
QCOMPARE(shown.editor().caretPosition(), 6);
// A wrapped line's end stays before its breaking space.
shown.key(Qt::Key_End);
QCOMPARE(shown.editor().caretPosition(), 10);
shown.key(Qt::Key_Up);
QCOMPARE(shown.editor().caretPosition(), 4);
shown.key(Qt::Key_Down);
shown.key(Qt::Key_Down);
QVERIFY(shown.editor().caretPosition() > 11 && shown.editor().caretPosition() <= 16);
// The last line keeps its space: End goes past it.
shown.key(Qt::Key_End, Qt::ControlModifier);
shown.type(QStringLiteral(" "));
shown.key(Qt::Key_Home);
shown.key(Qt::Key_End);
QCOMPARE(shown.editor().caretPosition(), 17);
}
void InlineTextEditorTests::aCaretPastTheBoxHasNoLine()
{
TextCanvas shown;
shown.session.cancelText();
// Room for one line: "beta" falls past the bottom.
shown.session.beginText(QRectF(10, 10, 300, 120));
shown.type(QStringLiteral("alpha beta"));
shown.key(Qt::Key_Up);
QCOMPARE(shown.caret(), 10);
shown.key(Qt::Key_Home);
QCOMPARE(shown.caret(), 0);
// The laid line still wraps before its space.
shown.key(Qt::Key_End);
QCOMPARE(shown.caret(), 5);
shown.key(Qt::Key_Up);
QCOMPARE(shown.caret(), 0);
shown.key(Qt::Key_End);
shown.key(Qt::Key_Right);
shown.key(Qt::Key_End);
QCOMPARE(shown.caret(), 10);
}
void InlineTextEditorTests::deletionsReachCharactersWordsAndLines()
{
TextCanvas shown;
shown.type(QStringLiteral("alpha beta gamma"));
shown.key(Qt::Key_Backspace, Qt::AltModifier);
QCOMPARE(shown.content(), QString("alpha beta "));
shown.key(Qt::Key_Backspace, Qt::ControlModifier);
QCOMPARE(shown.content(), QString());
shown.type(QStringLiteral("alpha beta"));
shown.key(Qt::Key_Home);
shown.key(Qt::Key_Delete, Qt::AltModifier);
QCOMPARE(shown.content(), QString(" beta"));
shown.key(Qt::Key_Delete);
QCOMPARE(shown.content(), QString("beta"));
shown.key(Qt::Key_Delete, Qt::ControlModifier);
QCOMPARE(shown.content(), QString());
// A character as a reader counts it: the emoji whole.
shown.type(QStringLiteral("a"));
shown.typeText(QStringLiteral("😀"));
QCOMPARE(shown.content(), QString("a😀"));
shown.key(Qt::Key_Backspace);
QCOMPARE(shown.content(), QString("a"));
// A letter and its accent move and go as one.
shown.typeText(QStringLiteral("e\u0301"));
shown.key(Qt::Key_Left);
QCOMPARE(shown.caret(), 1);
shown.key(Qt::Key_Right);
QCOMPARE(shown.caret(), 3);
shown.key(Qt::Key_Backspace);
QCOMPARE(shown.content(), QString("a"));
// A selection goes first, whatever the key.
shown.type(QStringLiteral("bc"));
shown.key(Qt::Key_Left, Qt::ShiftModifier);
shown.key(Qt::Key_Left, Qt::ShiftModifier);
shown.key(Qt::Key_Delete, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("a"));
// Shift adds nothing; the keypad's Delete deletes.
shown.type(QStringLiteral("b cd"));
shown.key(Qt::Key_Backspace, Qt::ShiftModifier);
QCOMPARE(shown.content(), QString("ab c"));
shown.key(Qt::Key_Backspace, Qt::ShiftModifier | Qt::AltModifier);
QCOMPARE(shown.content(), QString("ab "));
shown.key(Qt::Key_Home);
shown.key(Qt::Key_Delete, Qt::KeypadModifier);
QCOMPARE(shown.content(), QString("b "));
// At either end nothing goes, and the redo lasts.
shown.key(Qt::Key_Z, Qt::ControlModifier);
QCOMPARE(shown.content(), QString("ab "));
QCOMPARE(shown.caret(), 0);
shown.key(Qt::Key_Backspace);
shown.key(Qt::Key_End);
shown.key(Qt::Key_Delete);
shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(shown.content(), QString("b "));
}
void InlineTextEditorTests::returnTabEscapeAndFinishing()
{
TextCanvas shown;
shown.type(QStringLiteral("A"));
shown.key(Qt::Key_Return);
shown.key(Qt::Key_Tab);
shown.key(Qt::Key_Space);
shown.type(QStringLiteral("BM"));
QCOMPARE(shown.content(), QString("A\n\t BM"));
// Tool letters and Space type here: the canvas keeps none.
QCOMPARE(shown.session.tool(), NavigationTool::type);
QCOMPARE(shown.editor().logicalSize().height(), EditorSession::textBoxSize(shown.session.textDraft().value().style).height());
const size_t count = shown.session.document().value().layers.size();
shown.key(Qt::Key_Escape);
QVERIFY(!shown.session.textDraft() && !shown.canvas->inlineTextEditor());
QCOMPARE(shown.session.document().value().layers.size(), count);
shown.session.beginText(QPointF(40, 40), true);
shown.type(QStringLiteral("Kept"));
shown.key(Qt::Key_Return, Qt::ControlModifier);
QVERIFY(!shown.session.textDraft());
QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Kept"));
shown.session.editActiveText();
shown.key(Qt::Key_End);
shown.type(QStringLiteral("!"));
shown.key(Qt::Key_Enter, Qt::ControlModifier);
QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Kept!"));
}
void InlineTextEditorTests::altWithTheArrowsSetsSpacing()
{
TextCanvas shown;
shown.type(QStringLiteral("Spaced"));
const auto style = [&] { return shown.session.textDraft().value().style; };
shown.key(Qt::Key_Right, Qt::AltModifier);
QCOMPARE(style().tracking, 1.0);
shown.key(Qt::Key_Left, Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(style().tracking, -9.0);
// Up closes the lines from what Auto works out to.
shown.key(Qt::Key_Up, Qt::AltModifier);
QCOMPARE(style().leading, 72 * 1.2 - 1);
shown.key(Qt::Key_Down, Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(style().leading, 72 * 1.2 + 9);
shown.session.changeTextStyle([](LayerTextStyle &changed) { changed.leading = 5; });
shown.key(Qt::Key_Up, Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(style().leading, 1.0);
// Past the style's bounds nothing changes.
shown.session.changeTextStyle([](LayerTextStyle &changed) { changed.tracking = 995; });
shown.key(Qt::Key_Right, Qt::AltModifier | Qt::ShiftModifier);
QCOMPARE(style().tracking, 995.0);
QCOMPARE(shown.content(), QString("Spaced"));
QCOMPARE(shown.editor().caretPosition(), 6);
}
void InlineTextEditorTests::aFinalNewlineHasALineForTheCaret()
{
TextCanvas shown;
shown.session.cancelText();
// In a box, Return makes TextKit's extra line, the caret's.
shown.session.beginText(QRectF(10, 10, 300, 300));
shown.type(QStringLiteral("A"));
shown.key(Qt::Key_Return);
shown.key(Qt::Key_Home);
shown.type(QStringLiteral("B"));
QCOMPARE(shown.content(), QString("A\nB"));
// Point text keeps its measure; that line lies past it.
shown.session.cancelText();
shown.session.beginText(QPointF(20, 30), true);
shown.type(QStringLiteral("A"));
const QSizeF one = shown.editor().logicalSize();
shown.key(Qt::Key_Return);
QCOMPARE(shown.editor().logicalSize(), one);
shown.key(Qt::Key_Up);
QCOMPARE(shown.caret(), 0);
shown.key(Qt::Key_Down);
QCOMPARE(shown.caret(), 2);
shown.key(Qt::Key_Home);
shown.type(QStringLiteral("B"));
QCOMPARE(shown.content(), QString("A\nB"));
// A final U+2028 breaks the line the same way.
const QString separator(QChar::LineSeparator);
shown.session.cancelText();
shown.session.beginText(QRectF(10, 10, 300, 300));
shown.typeText(QStringLiteral("A") + separator);
shown.key(Qt::Key_Home);
shown.type(QStringLiteral("B"));
QCOMPARE(shown.content(), QStringLiteral("A") + separator + QStringLiteral("B"));
// A line ends before the separator that breaks it.
shown.key(Qt::Key_Home, Qt::ControlModifier);
shown.key(Qt::Key_End);
QCOMPARE(shown.caret(), 1);
}
void InlineTextEditorTests::arrowsFollowARightToLeftParagraph()
{
TextCanvas shown;
shown.typeText(QStringLiteral("אבג דה"));
// Right goes back through Hebrew, as Qt's fields move.
shown.key(Qt::Key_Home);
shown.key(Qt::Key_Left);
QCOMPARE(shown.caret(), 1);
shown.key(Qt::Key_Right);
QCOMPARE(shown.caret(), 0);
// A plain arrow collapses to its own side.
shown.key(Qt::Key_Left, Qt::ShiftModifier);
shown.key(Qt::Key_Left, Qt::ShiftModifier);
QCOMPARE(shown.selection(), (TextRange{0, 2}));
shown.key(Qt::Key_Right);
QCOMPARE(shown.selection(), (TextRange{0, 0}));
shown.key(Qt::Key_Left, Qt::ShiftModifier);
shown.key(Qt::Key_Left);
QCOMPARE(shown.selection(), (TextRange{1, 1}));
// Words go the same way.
shown.key(Qt::Key_Left, Qt::ControlModifier);
QCOMPARE(shown.caret(), 3);
shown.key(Qt::Key_Right, Qt::ControlModifier);
QCOMPARE(shown.caret(), 0);
// Each paragraph reads its own way, whatever parts them.
for (const QString &separator : {QStringLiteral("\n"), QStringLiteral("\r"), QString(QChar(0x2029))}) {
TextDraft draft = shown.session.textDraft().value();
draft.style.content = QStringLiteral("אב") + separator + QStringLiteral("ab");
shown.session.setTextDraft(draft);
shown.key(Qt::Key_End, Qt::ControlModifier);
shown.key(Qt::Key_Home);
shown.key(Qt::Key_Right);
QCOMPARE(shown.caret(), 4);
}
}
QTEST_MAIN(InlineTextEditorTests)
#include "InlineTextEditorTests.moc"