#include "InlineTextFixtures.h" #include #include // The Type tool's pointer: new text, clicks, drags, selections. namespace { // Where a prefix of a line ends, plus a nudge. QPointF after(TextCanvas &shown, const QString &prefix, double nudge = 1, int line = 0) { const LayerTextStyle style = shown.session.textDraft().value().style; const double padding = LayerTextStyle::padding; const double x = QFontMetricsF(TextLayout::font(style)).horizontalAdvance(prefix); return shown.editor().textTransform().map(QPointF(padding + x + nudge, padding + style.lineHeight() * (line + 0.5))); } // The bounds of the pixels two grabs disagree on. QRect changed(const QImage &from, const QImage &to) { QRect found; for (int y = 0; y < to.height(); ++y) { for (int x = 0; x < to.width(); ++x) { if (to.pixel(x, y) != from.pixel(x, y)) found |= QRect(x, y, 1, 1); } } return found; } } class InlineTextPointerTests : public QObject { Q_OBJECT private slots: void aClickOrADragBeginsText(); void aClickOpensTheTextUnderIt(); void clicksPlaceTheCaretAndDragsSelect(); void doubleAndTripleClicksTakeWordsAndParagraphs(); void aClickLandsAPreedit(); void anUndoneCanvasLeavesTheBoxInert(); void aLostReleaseDropsTheBox(); void shiftExtendsByTheLastClicksUnit(); void blankSpaceAndEmptyLinesTakeClicks(); }; void InlineTextPointerTests::aClickOrADragBeginsText() { TextCanvas shown; 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)); QVERIFY(!shown.session.textDraft().value().style.boxSize); shown.session.cancelText(); // A drag draws a box, shown in the accent meanwhile. const QImage before = shown.canvas->grab().toImage(); shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); const QImage drawing = shown.canvas->grab().toImage(); const QRect drawn = changed(before, drawing); QVERIFY2(std::abs(drawn.left() - 40) <= 1 && std::abs(drawn.right() - 140) <= 1 && std::abs(drawn.top() - 50) <= 1 && std::abs(drawn.bottom() - 110) <= 1, qPrintable(QString("%1 %2 %3 %4").arg(drawn.left()).arg(drawn.top()).arg(drawn.right()).arg(drawn.bottom()))); QCOMPARE(drawing.pixel(90, 80), before.pixel(90, 80)); // One point wide on whole points: two rows, half each. const QColor accent = shown.canvas->palette().color(QPalette::Highlight); const auto half = [](int line, int ground, int shown) { return std::abs(shown - (line + ground) / 2) <= 2; }; for (const int row : {49, 50}) { const QColor ground = before.pixelColor(90, row), edge = drawing.pixelColor(90, row); QVERIFY2(half(accent.red(), ground.red(), edge.red()) && half(accent.green(), ground.green(), edge.green()) && half(accent.blue(), ground.blue(), edge.blue()), qPrintable(edge.name())); } shown.release(QPointF(140, 110)); QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(100, 60))); QCOMPARE(shown.session.textDraft().value().origin, QPointF(40, 50)); // Under four pixels both ways: a click; else a box. const std::array>, 3> drags{ {{QPointF(43, 53), std::nullopt}, {QPointF(44, 54), QSizeF(16, 16)}, {QPointF(43, 100), QSizeF(16, 50)}}}; for (const auto &[to, box] : drags) { shown.session.cancelText(); shown.drag(QPointF(40, 50), to); QCOMPARE(shown.session.textDraft().value().style.boxSize, box); } shown.session.cancelText(); // Escape, or another tool, drops the box being drawn. shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); shown.key(Qt::Key_Escape); shown.release(QPointF(140, 110)); QVERIFY(!shown.session.textDraft()); QCOMPARE(shown.canvas->grab().toImage().pixel(90, 50), before.pixel(90, 50)); shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); shown.session.selectTool(NavigationTool::marquee); shown.session.selectTool(NavigationTool::type); shown.release(QPointF(140, 110)); QVERIFY(!shown.session.textDraft()); // A box past the limits is refused; its outline goes. shown.press(QPointF(40, 50)); shown.move(QPointF(40'040, 110)); QCoreApplication::processEvents(); PaintSpy spy(*shown.canvas); shown.release(QPointF(40'040, 110)); QVERIFY(!shown.session.textDraft()); QTRY_VERIFY(spy.painted.contains(QPoint(40, 80))); QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); } void InlineTextPointerTests::aClickOpensTheTextUnderIt() { TextCanvas shown; // A click beside open text keeps it, then begins anew. shown.type(QStringLiteral("Kept")); 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)); shown.session.cancelText(); // A click opens text, active or not, the caret there. const QUuid kept = shown.session.document().value().layers.back().id; shown.session.selectLayer(shown.session.document().value().layers.front().id); const QPointF on(20 + LayerTextStyle::padding + 1, 80); shown.click(on); QCOMPARE(shown.session.textDraft().value().layerID, std::optional(kept)); QCOMPARE(shown.caret(), 0); shown.session.cancelText(); // Hidden text stays shut: the click begins new text. shown.session.toggleLayerVisibility(kept); shown.click(on); QVERIFY(!shown.session.textDraft().value().layerID); shown.session.cancelText(); shown.session.toggleLayerVisibility(kept); // A new box widened leftwards lands where it shows. shown.drag(QPointF(40, 150), QPointF(240, 250)); shown.type(QStringLiteral("Box")); const QPointF left = shown.editor().boxTransform().map(QPointF(0, 50)); shown.drag(left, left - QPointF(20, 0)); QVERIFY(shown.session.finishText()); QCOMPARE(shown.session.activeLayer().value().transform.origin, QPointF(20, 150)); } void InlineTextPointerTests::clicksPlaceTheCaretAndDragsSelect() { TextCanvas shown; shown.type(QStringLiteral("alpha beta")); shown.click(after(shown, QStringLiteral("alp"))); QCOMPARE(shown.selection(), (TextRange{3, 3})); // A drag selects; Shift-click takes the selection on. shown.drag(after(shown, QStringLiteral("al")), after(shown, QStringLiteral("alpha b"))); QCOMPARE(shown.selection(), (TextRange{2, 7})); shown.click(after(shown, QStringLiteral("alpha be")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{2, 8})); // Shift's drag keeps that anchor, past it both ways. shown.press(after(shown, QStringLiteral("alpha ")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{2, 6})); shown.move(after(shown, QStringLiteral("a")), Qt::ShiftModifier); QCOMPARE(shown.editor().anchor(), 2); QCOMPARE(shown.caret(), 1); shown.release(after(shown, QStringLiteral("a")), Qt::ShiftModifier); // Without the button held a move drags nothing. shown.press(after(shown, QStringLiteral("alp"))); shown.hover(after(shown, QStringLiteral("alpha b"))); QCOMPARE(shown.selection(), (TextRange{3, 3})); shown.release(after(shown, QStringLiteral("alp"))); // A click on a later line finds that line. shown.key(Qt::Key_End); shown.key(Qt::Key_Return); shown.type(QStringLiteral("gamma")); shown.click(after(shown, QStringLiteral("ga"), 1, 1)); QCOMPARE(shown.caret(), 13); // Clicks go through a flipped layer's mirror, as it shows. QVERIFY(shown.session.finishText()); const QUuid id = shown.session.activeLayerID().value(); rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.flipX = true; }); shown.session.editActiveText(); shown.click(after(shown, QStringLiteral("al"))); QCOMPARE(shown.caret(), 2); } void InlineTextPointerTests::doubleAndTripleClicksTakeWordsAndParagraphs() { TextCanvas shown; shown.type(QStringLiteral("alpha beta")); // Qt sends a double click's second press by itself. const auto doubleClick = [&](QPointF at) { shown.click(at); QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); }; // The word under the pointer, from any of its letters. const QPointF word = after(shown, QStringLiteral("alpha be")); doubleClick(word); QCOMPARE(shown.selection(), (TextRange{6, 10})); shown.release(word); doubleClick(after(shown, QStringLiteral("alpha "), 2)); QCOMPARE(shown.selection(), (TextRange{6, 10})); shown.release(word); doubleClick(after(shown, QStringLiteral("alpha"), -2)); QCOMPARE(shown.selection(), (TextRange{0, 5})); shown.release(word); // Its drag goes by words, forwards and back past it. doubleClick(word); shown.move(after(shown, QStringLiteral("alpha bet"))); QCOMPARE(shown.editor().anchor(), 6); QCOMPARE(shown.caret(), 10); shown.move(after(shown, QStringLiteral("a"))); QCOMPARE(shown.editor().anchor(), 10); QCOMPARE(shown.caret(), 0); shown.move(after(shown, QStringLiteral("alpha bet"))); QCOMPARE(shown.editor().anchor(), 6); QCOMPARE(shown.caret(), 10); shown.release(word); // A third click soon after takes the paragraph and break. TextDraft draft = shown.session.textDraft().value(); draft.style.content = QStringLiteral("alpha beta\r\ngamma"); shown.session.setTextDraft(draft); doubleClick(word); shown.release(word); shown.click(word); QCOMPARE(shown.selection(), (TextRange{0, 12})); // Fourth clicks and far third clicks are single again. shown.click(word); QCOMPARE(shown.selection(), (TextRange{8, 8})); doubleClick(word); shown.release(word); shown.click(after(shown, QStringLiteral("al"))); QCOMPARE(shown.selection(), (TextRange{2, 2})); } void InlineTextPointerTests::aClickLandsAPreedit() { TextCanvas shown; shown.type(QStringLiteral("ab")); shown.compose(QStringLiteral("ni")); // Qt asks the input method to commit; else it lands. shown.click(after(shown, QStringLiteral("a"))); QVERIFY(!shown.editor().marked()); QCOMPARE(shown.content(), QString("abni")); QCOMPARE(shown.caret(), 1); shown.key(Qt::Key_Z, Qt::ControlModifier); QCOMPARE(shown.content(), QString()); // A handle's press lands it too. shown.type(QStringLiteral("ab")); shown.compose(QStringLiteral("ni")); const QSizeF size = shown.editor().logicalSize(); const QPointF right = shown.editor().boxTransform().map(QPointF(size.width(), size.height() / 2)); shown.press(right); QVERIFY(!shown.editor().marked()); QCOMPARE(shown.content(), QString("abni")); shown.release(right); } void InlineTextPointerTests::anUndoneCanvasLeavesTheBoxInert() { TextCanvas shown; shown.session.cancelText(); // Undo takes the canvas mid-drag: the box makes nothing. shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); shown.session.undo(); shown.session.undo(); QVERIFY(!shown.session.document()); shown.move(QPointF(160, 130)); const QImage blank = shown.canvas->grab().toImage(); QCOMPARE(blank.pixel(40, 80), blank.pixel(300, 250)); shown.release(QPointF(160, 130)); QVERIFY(!shown.session.textDraft()); } void InlineTextPointerTests::aLostReleaseDropsTheBox() { TextCanvas shown; shown.session.cancelText(); const QImage before = shown.canvas->grab().toImage(); // A move without the button: its release was lost. shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); QCoreApplication::processEvents(); PaintSpy hover(*shown.canvas); shown.hover(QPointF(200, 150)); QTRY_VERIFY(hover.painted.contains(QPoint(40, 80))); QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); shown.release(QPointF(200, 150)); QVERIFY(!shown.session.textDraft()); // A press after a lost release starts afresh. shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); QCoreApplication::processEvents(); PaintSpy press(*shown.canvas); 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)); QVERIFY(!shown.session.textDraft().value().style.boxSize); shown.session.cancelText(); // So does a lost focus. shown.press(QPointF(40, 50)); shown.move(QPointF(140, 110)); QLineEdit field(&shown.window); field.show(); field.setFocus(); QTRY_VERIFY(field.hasFocus()); shown.release(QPointF(140, 110)); QVERIFY(!shown.session.textDraft()); QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); } void InlineTextPointerTests::shiftExtendsByTheLastClicksUnit() { TextCanvas shown; shown.type(QStringLiteral("alpha beta gamma")); const auto doubleClick = [&](QPointF at) { shown.click(at); QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); shown.release(at); }; // Before any click, Shift extends from the caret. shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); QCOMPARE(shown.editor().anchor(), 16); QCOMPARE(shown.caret(), 13); // After a double click, by whole words either way. doubleClick(after(shown, QStringLiteral("al"))); QCOMPARE(shown.selection(), (TextRange{0, 5})); shown.click(after(shown, QStringLiteral("alpha be")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 10})); shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 16})); doubleClick(after(shown, QStringLiteral("alpha be"))); shown.click(after(shown, QStringLiteral("al")), Qt::ShiftModifier); QCOMPARE(shown.editor().anchor(), 10); QCOMPARE(shown.caret(), 0); shown.press(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{6, 16})); shown.move(after(shown, QStringLiteral("al")), Qt::ShiftModifier); QCOMPARE(shown.editor().anchor(), 10); QCOMPARE(shown.caret(), 0); shown.release(after(shown, QStringLiteral("al")), Qt::ShiftModifier); // A key's selection brings characters back. shown.key(Qt::Key_Right, Qt::ShiftModifier); shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{10, 13})); doubleClick(after(shown, QStringLiteral("alpha be"))); shown.click(after(shown, QStringLiteral("al")), Qt::ShiftModifier); shown.key(Qt::Key_Left); shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 13})); // So does other text, or a style from anywhere. doubleClick(after(shown, QStringLiteral("al"))); TextDraft draft = shown.session.textDraft().value(); draft.style.content = QStringLiteral("omega beta gamma"); shown.session.setTextDraft(draft); shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 8})); doubleClick(after(shown, QStringLiteral("om"))); shown.key(Qt::Key_Right, Qt::AltModifier); shown.key(Qt::Key_Left, Qt::AltModifier); shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 8})); doubleClick(after(shown, QStringLiteral("om"))); shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 60; }); shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); QCOMPARE(shown.selection(), (TextRange{0, 8})); // A style set mid-drag ends the drag; so does typing. shown.press(after(shown, QStringLiteral("omega b"))); shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 72; }); shown.move(after(shown, QStringLiteral("om"))); QCOMPARE(shown.selection(), (TextRange{7, 7})); shown.release(after(shown, QStringLiteral("om"))); shown.press(after(shown, QStringLiteral("om"))); shown.type(QStringLiteral("X")); shown.move(after(shown, QStringLiteral("omega b"))); QCOMPARE(shown.selection(), (TextRange{3, 3})); shown.release(after(shown, QStringLiteral("omega b"))); // Even the same letter typed over the letter selected. shown.press(after(shown, QStringLiteral("om"))); shown.move(after(shown, QStringLiteral("omX"))); QCOMPARE(shown.selection(), (TextRange{2, 3})); shown.type(QStringLiteral("X")); shown.move(after(shown, QStringLiteral("omXeg"))); QCOMPARE(shown.selection(), (TextRange{3, 3})); shown.release(after(shown, QStringLiteral("omXeg"))); } void InlineTextPointerTests::blankSpaceAndEmptyLinesTakeClicks() { TextCanvas shown; shown.session.cancelText(); shown.drag(QPointF(20, 20), QPointF(380, 280)); TextDraft draft = shown.session.textDraft().value(); draft.style.fontSize = 24; draft.style.content = QStringLiteral("abc\ndef"); shown.session.setTextDraft(draft); // Below the lines is the end; above them, the start. shown.click(after(shown, QString(), 1, 2)); QCOMPARE(shown.caret(), 7); shown.press(after(shown, QStringLiteral("d"), 1, 1)); shown.move(after(shown, QStringLiteral("d"), 1, -1)); QCOMPARE(shown.editor().anchor(), 5); QCOMPARE(shown.caret(), 0); shown.release(after(shown, QStringLiteral("d"), 1, -1)); // An empty last line has no word or paragraph. draft = shown.session.textDraft().value(); draft.style.content = QStringLiteral("abc\n\ndef\n"); shown.session.setTextDraft(draft); const auto clicks = [&](QPointF at, int count) { shown.click(at); QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); shown.release(at); if (count == 3) shown.click(at); }; clicks(after(shown, QString(), 1, 3), 3); QCOMPARE(shown.selection(), (TextRange{9, 9})); clicks(after(shown, QString(), 1, 3), 2); QCOMPARE(shown.selection(), (TextRange{9, 9})); clicks(after(shown, QStringLiteral("d"), 1, 2), 2); QCOMPARE(shown.selection(), (TextRange{5, 8})); // An empty line between others is its break. clicks(after(shown, QString(), 1, 1), 3); QCOMPARE(shown.selection(), (TextRange{4, 5})); // Past the text, a double click takes its last word. draft = shown.session.textDraft().value(); draft.style.content = QStringLiteral("abc def"); shown.session.setTextDraft(draft); clicks(after(shown, QStringLiteral("abc def"), 20), 2); QCOMPARE(shown.selection(), (TextRange{4, 7})); // A final line separator leaves no word either. draft = shown.session.textDraft().value(); draft.style.content = QStringLiteral("abc") + QChar(QChar::LineSeparator); shown.session.setTextDraft(draft); clicks(after(shown, QString(), 1, 1), 2); QCOMPARE(shown.selection(), (TextRange{4, 4})); // Below an overfull box's lines: the end of what shows. draft = shown.session.textDraft().value(); draft.style.boxSize = QSizeF(150, 100); draft.style.content = QStringLiteral("aaa bbb ccc ddd eee fff"); shown.session.setTextDraft(draft); shown.click(after(shown, QStringLiteral("ccc"), 1, 1)); shown.key(Qt::Key_End); const int shows = shown.caret(); QVERIFY(shows < 23); shown.click(after(shown, QString(), 1, 2)); QCOMPARE(shown.caret(), shows); } QTEST_MAIN(InlineTextPointerTests) #include "InlineTextPointerTests.moc"