Gestures: flick, long-press alternates, trackpad, layer slide, rollover
- PointerTracker: per-finger gesture state machine (pure, unit-tested) - AlternatesPicker + AlternatesPopup.qml: long-press choices, finger selects - Space long-press trackpad moves the cursor with arrow keysyms - Slide from .?123 onto a symbol types it and switches back - New finger commits pending taps (iOS rollover) - Flick animation: secondary symbol slides down as the finger moves - Transparent popup margin with an input region that passes clicks through - tst_keyboardsurface: end-to-end gesture tests on the real QML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,10 @@ sloposk_add_test(tst_shiftstate)
|
||||
sloposk_add_test(tst_layoutloader)
|
||||
sloposk_add_test(tst_keygeometry)
|
||||
sloposk_add_test(tst_controller)
|
||||
sloposk_add_test(tst_pointertracker)
|
||||
sloposk_add_test(tst_alternatespicker)
|
||||
|
||||
# Needs the QML module itself, not just the C++ backing library.
|
||||
sloposk_add_test(tst_keyboardsurface)
|
||||
target_link_libraries(tst_keyboardsurface PRIVATE sloposk_coreplugin)
|
||||
set_tests_properties(tst_keyboardsurface PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "input/AlternatesPicker.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
class TestAlternatesPicker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void opensAboveKeyExtendingRight()
|
||||
{
|
||||
AlternatesPicker picker;
|
||||
picker.open(QRectF(100, 100, 50, 60), {u"é"_s, u"è"_s, u"ê"_s}, 1000, 8);
|
||||
QVERIFY(picker.isOpen());
|
||||
QCOMPARE(picker.rect(), QRectF(100, 32, 150, 60));
|
||||
QCOMPARE(picker.selectedText(), u"é");
|
||||
}
|
||||
|
||||
void staysInsideRightEdgeAndSelectsUnderFinger()
|
||||
{
|
||||
AlternatesPicker picker;
|
||||
picker.open(QRectF(900, 100, 50, 60), {u"a"_s, u"b"_s, u"c"_s}, 1000, 8);
|
||||
QCOMPARE(picker.rect().left(), 850.0);
|
||||
QCOMPARE(picker.selectedText(), u"b"); // key center x=925 lies over the second cell
|
||||
}
|
||||
|
||||
void selectionFollowsFingerAndClamps()
|
||||
{
|
||||
AlternatesPicker picker;
|
||||
picker.open(QRectF(100, 100, 50, 60), {u"a"_s, u"b"_s, u"c"_s}, 1000, 8);
|
||||
picker.selectAt(210);
|
||||
QCOMPARE(picker.selected(), 2);
|
||||
picker.selectAt(-50);
|
||||
QCOMPARE(picker.selected(), 0);
|
||||
picker.selectAt(5000);
|
||||
QCOMPARE(picker.selected(), 2);
|
||||
}
|
||||
|
||||
void closeClears()
|
||||
{
|
||||
AlternatesPicker picker;
|
||||
picker.open(QRectF(0, 0, 10, 10), {u"a"_s}, 100, 0);
|
||||
picker.close();
|
||||
QVERIFY(!picker.isOpen());
|
||||
QVERIFY(picker.selectedText().isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestAlternatesPicker)
|
||||
#include "tst_alternatespicker.moc"
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "engine/InputEngine.h"
|
||||
#include "input/KeyboardController.h"
|
||||
#include "layout/LayoutLoader.h"
|
||||
#include "layout/LayoutModel.h"
|
||||
#include "layout/LayoutRepository.h"
|
||||
|
||||
@@ -106,6 +105,22 @@ private Q_SLOTS:
|
||||
QCOMPARE(model.layerName(), u"numbers");
|
||||
}
|
||||
|
||||
void slideTypesSymbolAndSwitchesBack()
|
||||
{
|
||||
KeyDef layer = makeKey(KeyType::LayerSwitch);
|
||||
layer.target = u"numbers"_s;
|
||||
controller->keyDown(layer, 0);
|
||||
controller->slideReleased(makeKey(KeyType::Character, u"5"_s), 100);
|
||||
QCOMPARE(engine.output, u"5");
|
||||
QCOMPARE(model.layerName(), u"letters");
|
||||
}
|
||||
|
||||
void trackpadMovesCursor()
|
||||
{
|
||||
controller->moveCursor({2, -1});
|
||||
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Up}));
|
||||
}
|
||||
|
||||
void globeCyclesLayouts()
|
||||
{
|
||||
QCOMPARE(model.layoutName(), u"English (US)");
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// End-to-end gesture tests: real Keyboard.qml, synthetic touch events.
|
||||
// Set SLOPOSK_SNAPSHOT_DIR to also save what the keyboard looks like mid-gesture.
|
||||
|
||||
#include "engine/InputEngine.h"
|
||||
#include "input/KeyboardController.h"
|
||||
#include "input/KeyboardSurface.h"
|
||||
#include "layout/LayoutModel.h"
|
||||
#include "layout/LayoutRepository.h"
|
||||
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlEngine>
|
||||
#include <QQmlExtensionPlugin>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QTest>
|
||||
#include <xkbcommon/xkbcommon-keysyms.h>
|
||||
|
||||
Q_IMPORT_QML_PLUGIN(SloposkPlugin)
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeEngine : public InputEngine
|
||||
{
|
||||
public:
|
||||
void commitText(const QString &text) override { output += text; }
|
||||
void sendKeysym(quint32 keysym) override { keysyms.append(keysym); }
|
||||
|
||||
QString output;
|
||||
QList<quint32> keysyms;
|
||||
};
|
||||
|
||||
constexpr QPoint KeyboardOffset(0, 100); // room above for popups
|
||||
constexpr int LongPressWaitMs = 600;
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestKeyboardSurface : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
FakeEngine engine;
|
||||
LayoutRepository repo;
|
||||
LayoutModel model;
|
||||
std::unique_ptr<KeyboardController> controller;
|
||||
QQmlEngine qml;
|
||||
QQuickWindow window;
|
||||
QQuickItem *keyboard = nullptr;
|
||||
QPointingDevice *touchscreen = QTest::createTouchDevice();
|
||||
|
||||
// Window coordinates of the center of the key labelled `label`.
|
||||
QPoint keyCenter(const QString &label, const QString &type = u"char"_s)
|
||||
{
|
||||
for (int i = 0; i < model.rowCount(); ++i) {
|
||||
const QModelIndex idx = model.index(i);
|
||||
if (idx.data(LayoutModel::KeyTypeRole) == type
|
||||
&& (label.isEmpty() || idx.data(LayoutModel::LabelRole) == label))
|
||||
return model.keyRect(i).center().toPoint() + KeyboardOffset;
|
||||
}
|
||||
qFatal("no key %s", qPrintable(label));
|
||||
}
|
||||
|
||||
// Qt Quick delivers touch moves on the next frame; let one pass.
|
||||
void settle() { QTest::qWait(50); }
|
||||
|
||||
void snapshot(const QString &name)
|
||||
{
|
||||
settle();
|
||||
const QString dir = qEnvironmentVariable("SLOPOSK_SNAPSHOT_DIR");
|
||||
if (!dir.isEmpty())
|
||||
window.grabWindow().save(dir + u'/' + name + u".png"_s);
|
||||
}
|
||||
|
||||
private Q_SLOTS:
|
||||
void initTestCase()
|
||||
{
|
||||
Q_INIT_RESOURCE(layouts);
|
||||
repo.loadDirectory(u":/layouts"_s);
|
||||
repo.finalize(u"en_US"_s);
|
||||
controller = std::make_unique<KeyboardController>(engine, model, repo);
|
||||
|
||||
QQmlComponent component(&qml, "Sloposk", "Keyboard");
|
||||
keyboard = qobject_cast<QQuickItem *>(component.createWithInitialProperties({
|
||||
{u"model"_s, QVariant::fromValue(&model)},
|
||||
{u"controller"_s, QVariant::fromValue(controller.get())},
|
||||
}));
|
||||
QVERIFY2(keyboard, qPrintable(component.errorString()));
|
||||
keyboard->setParentItem(window.contentItem());
|
||||
keyboard->setPosition(KeyboardOffset);
|
||||
keyboard->setSize({1000, 300});
|
||||
window.setColor(QColor(u"#d1d3d9"_s));
|
||||
window.resize(1000, 400);
|
||||
window.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&window));
|
||||
}
|
||||
|
||||
void init()
|
||||
{
|
||||
engine = {};
|
||||
}
|
||||
|
||||
void tapTypesLetter()
|
||||
{
|
||||
QTest::touchEvent(&window, touchscreen).press(0, keyCenter(u"q"_s));
|
||||
QTest::touchEvent(&window, touchscreen).release(0, keyCenter(u"q"_s));
|
||||
QCOMPARE(engine.output, u"q");
|
||||
}
|
||||
|
||||
void flickDownTypesSecondarySymbol()
|
||||
{
|
||||
const QPoint w = keyCenter(u"w"_s);
|
||||
QTest::touchEvent(&window, touchscreen).press(0, w);
|
||||
QTest::touchEvent(&window, touchscreen).move(0, w + QPoint(0, 15));
|
||||
snapshot(u"flick-halfway"_s);
|
||||
const int wIndex = model.keyAt(w - KeyboardOffset);
|
||||
QVERIFY(model.index(wIndex).data(LayoutModel::FlickProgressRole).toReal() > 0.3);
|
||||
QTest::touchEvent(&window, touchscreen).move(0, w + QPoint(0, 40));
|
||||
QTest::touchEvent(&window, touchscreen).release(0, w + QPoint(0, 40));
|
||||
QCOMPARE(engine.output, u"2");
|
||||
}
|
||||
|
||||
void longPressPicksAlternate()
|
||||
{
|
||||
const QPoint e = keyCenter(u"e"_s);
|
||||
const qreal cell = model.keyRect(0).width();
|
||||
QTest::touchEvent(&window, touchscreen).press(0, e);
|
||||
QTest::qWait(LongPressWaitMs);
|
||||
auto *surface = qobject_cast<KeyboardSurface *>(keyboard);
|
||||
QVERIFY(surface->alternates()->isOpen());
|
||||
|
||||
QTest::touchEvent(&window, touchscreen).move(0, e + QPoint(int(cell), 0));
|
||||
snapshot(u"alternates"_s);
|
||||
QTest::touchEvent(&window, touchscreen).release(0, e + QPoint(int(cell), 0));
|
||||
QCOMPARE(engine.output, u"è");
|
||||
QVERIFY(!surface->alternates()->isOpen());
|
||||
}
|
||||
|
||||
void secondFingerCommitsFirst()
|
||||
{
|
||||
const QPoint a = keyCenter(u"a"_s), s = keyCenter(u"s"_s);
|
||||
QTest::touchEvent(&window, touchscreen).press(0, a);
|
||||
QTest::touchEvent(&window, touchscreen).stationary(0).press(1, s);
|
||||
QCOMPARE(engine.output, u"a");
|
||||
QTest::touchEvent(&window, touchscreen).release(0, a).stationary(1);
|
||||
QTest::touchEvent(&window, touchscreen).release(1, s);
|
||||
QCOMPARE(engine.output, u"as");
|
||||
}
|
||||
|
||||
void slideFromNumbersKeyTypesAndReturns()
|
||||
{
|
||||
const QPoint layerKey = keyCenter(u".?123"_s, u"layer"_s);
|
||||
QTest::touchEvent(&window, touchscreen).press(0, layerKey);
|
||||
QCOMPARE(model.layerName(), u"numbers");
|
||||
const QPoint five = keyCenter(u"5"_s);
|
||||
QTest::touchEvent(&window, touchscreen).move(0, five);
|
||||
QTest::touchEvent(&window, touchscreen).release(0, five);
|
||||
QCOMPARE(engine.output, u"5");
|
||||
QCOMPARE(model.layerName(), u"letters");
|
||||
}
|
||||
|
||||
void spaceTrackpadMovesCursor()
|
||||
{
|
||||
const QPoint space = keyCenter({}, u"space"_s);
|
||||
QTest::touchEvent(&window, touchscreen).press(0, space);
|
||||
QTest::qWait(LongPressWaitMs);
|
||||
QTest::touchEvent(&window, touchscreen).move(0, space + QPoint(45, 0));
|
||||
snapshot(u"trackpad"_s);
|
||||
QTest::touchEvent(&window, touchscreen).release(0, space + QPoint(45, 0));
|
||||
QCOMPARE(engine.output, QString());
|
||||
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Right}));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestKeyboardSurface)
|
||||
#include "tst_keyboardsurface.moc"
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "input/PointerTracker.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
using Gesture = PointerTracker::Gesture;
|
||||
|
||||
namespace {
|
||||
|
||||
KeyDef letter(const QString &flick = {}, const QStringList &alternates = {})
|
||||
{
|
||||
KeyDef k;
|
||||
k.label = u"e"_s;
|
||||
k.flick = flick;
|
||||
k.alternates = alternates;
|
||||
return k;
|
||||
}
|
||||
|
||||
KeyDef ofType(KeyType type)
|
||||
{
|
||||
KeyDef k;
|
||||
k.type = type;
|
||||
return k;
|
||||
}
|
||||
|
||||
PointerTracker::Config config()
|
||||
{
|
||||
PointerTracker::Config c;
|
||||
c.slop = 10;
|
||||
c.flickDistance = 30;
|
||||
c.trackpadStep = 10;
|
||||
c.trackpadRowStep = 40;
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestPointerTracker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void smallMovementStaysTap()
|
||||
{
|
||||
PointerTracker t(letter(u"3"_s), {0, 0}, config());
|
||||
t.moveTo({5, 8});
|
||||
QCOMPARE(t.gesture(), Gesture::Tap);
|
||||
}
|
||||
|
||||
void downwardMovementFlicks()
|
||||
{
|
||||
PointerTracker t(letter(u"3"_s), {0, 0}, config());
|
||||
t.moveTo({2, 15});
|
||||
QCOMPARE(t.gesture(), Gesture::Flick);
|
||||
QCOMPARE(t.flickProgress(), 0.5);
|
||||
QVERIFY(!t.flickCommitted());
|
||||
t.moveTo({2, 45});
|
||||
QVERIFY(t.flickCommitted());
|
||||
}
|
||||
|
||||
void flickBackUpUncommits()
|
||||
{
|
||||
PointerTracker t(letter(u"3"_s), {0, 0}, config());
|
||||
t.moveTo({0, 40});
|
||||
t.moveTo({0, 5});
|
||||
QCOMPARE(t.gesture(), Gesture::Flick);
|
||||
QVERIFY(!t.flickCommitted());
|
||||
}
|
||||
|
||||
void sidewaysMovementDoesNotFlick()
|
||||
{
|
||||
PointerTracker t(letter(u"3"_s), {0, 0}, config());
|
||||
t.moveTo({30, 15});
|
||||
QCOMPARE(t.gesture(), Gesture::Tap);
|
||||
}
|
||||
|
||||
void keysWithoutFlickNeverFlick()
|
||||
{
|
||||
PointerTracker t(letter(), {0, 0}, config());
|
||||
t.moveTo({0, 40});
|
||||
QCOMPARE(t.gesture(), Gesture::Tap);
|
||||
}
|
||||
|
||||
void holdingStillOpensAlternates()
|
||||
{
|
||||
PointerTracker t(letter({}, {u"é"_s}), {0, 0}, config());
|
||||
QVERIFY(t.wantsLongPress());
|
||||
QVERIFY(t.longPressElapsed());
|
||||
QCOMPARE(t.gesture(), Gesture::LongPress);
|
||||
}
|
||||
|
||||
void movingCancelsLongPress()
|
||||
{
|
||||
PointerTracker t(letter({}, {u"é"_s}), {0, 0}, config());
|
||||
t.moveTo({20, 0});
|
||||
QVERIFY(!t.longPressElapsed());
|
||||
QCOMPARE(t.gesture(), Gesture::Tap);
|
||||
}
|
||||
|
||||
void spaceLongPressBecomesTrackpad()
|
||||
{
|
||||
PointerTracker t(ofType(KeyType::Space), {100, 0}, config());
|
||||
QVERIFY(t.longPressElapsed());
|
||||
QCOMPARE(t.gesture(), Gesture::Trackpad);
|
||||
|
||||
t.moveTo({125, 0});
|
||||
QCOMPARE(t.takeTrackpadSteps(), QPoint(2, 0));
|
||||
t.moveTo({131, 0});
|
||||
QCOMPARE(t.takeTrackpadSteps(), QPoint(1, 0));
|
||||
t.moveTo({95, -45});
|
||||
QCOMPARE(t.takeTrackpadSteps(), QPoint(-3, -1));
|
||||
}
|
||||
|
||||
void layerKeySlides()
|
||||
{
|
||||
PointerTracker t(ofType(KeyType::LayerSwitch), {0, 0}, config());
|
||||
t.moveTo({40, -30});
|
||||
QCOMPARE(t.gesture(), Gesture::Slide);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestPointerTracker)
|
||||
#include "tst_pointertracker.moc"
|
||||
Reference in New Issue
Block a user