- Template sections placed above or to the right of the typing keys; the user picks any mix in settings, typing keys included - keysym keys send XKB keysyms on press, optionally repeating - Geometry lays blocks side by side with one key unit; stretch keys are resolved at layout time so composed blocks line up - Toggling a block resizes the window so keys keep their size Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
7.7 KiB
C++
244 lines
7.7 KiB
C++
#include "FakeEngine.h"
|
|
#include "input/KeyboardController.h"
|
|
#include "layout/LayoutModel.h"
|
|
#include "TestLayouts.h"
|
|
|
|
#include <QTest>
|
|
#include <xkbcommon/xkbcommon-keysyms.h>
|
|
|
|
using namespace Qt::StringLiterals;
|
|
|
|
namespace {
|
|
|
|
KeyDef makeKey(KeyType type, const QString &label = {})
|
|
{
|
|
KeyDef k;
|
|
k.type = type;
|
|
k.label = label;
|
|
return k;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class TestController : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
FakeEngine engine;
|
|
LayoutModel model;
|
|
std::unique_ptr<KeyboardController> controller;
|
|
qint64 now = 0;
|
|
|
|
void tap(const KeyDef &key, KeyOutput output = KeyOutput::Primary, const QString &alternate = {})
|
|
{
|
|
now += 1000; // far apart: never a double tap
|
|
controller->keyDown(key, now);
|
|
controller->keyUp(key, output, alternate, now + 50);
|
|
}
|
|
|
|
private Q_SLOTS:
|
|
void init()
|
|
{
|
|
engine = {};
|
|
model.setLayout(TestLayouts::build("us"));
|
|
model.setVisibleSections({MainSectionId});
|
|
controller = std::make_unique<KeyboardController>(engine, model);
|
|
}
|
|
|
|
void typesLowercase()
|
|
{
|
|
tap(makeKey(KeyType::Character, u"a"_s));
|
|
QCOMPARE(engine.output, u"a");
|
|
}
|
|
|
|
void shiftCapitalizesOneCharacter()
|
|
{
|
|
const KeyDef a = makeKey(KeyType::Character, u"a"_s);
|
|
tap(makeKey(KeyType::Shift));
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
|
tap(a);
|
|
tap(a);
|
|
QCOMPARE(engine.output, u"Aa");
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
|
|
}
|
|
|
|
void flickAndAlternateOutputs()
|
|
{
|
|
KeyDef e = makeKey(KeyType::Character, u"e"_s);
|
|
e.flick = u"3"_s;
|
|
tap(e, KeyOutput::Flick);
|
|
tap(e, KeyOutput::Alternate, u"é"_s);
|
|
QCOMPARE(engine.output, u"3é");
|
|
}
|
|
|
|
void backspaceActsOnPress()
|
|
{
|
|
controller->keyDown(makeKey(KeyType::Backspace), 0);
|
|
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_BackSpace});
|
|
}
|
|
|
|
void enterSendsReturn()
|
|
{
|
|
tap(makeKey(KeyType::Enter));
|
|
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_Return});
|
|
}
|
|
|
|
void layerSwitchChangesModelLayer()
|
|
{
|
|
KeyDef layer = makeKey(KeyType::LayerSwitch);
|
|
layer.target = u"numbers"_s;
|
|
controller->keyDown(layer, 0);
|
|
QCOMPARE(model.layerName(), u"numbers");
|
|
}
|
|
|
|
void backspaceStopsOnRelease()
|
|
{
|
|
const KeyDef backspace = makeKey(KeyType::Backspace);
|
|
controller->keyDown(backspace, 0);
|
|
controller->keyUp(backspace, KeyOutput::Primary, {}, 50);
|
|
QTest::qWait(700); // past the repeat delay
|
|
QCOMPARE(engine.keysyms.size(), 1);
|
|
}
|
|
|
|
void capitalizesAtSentenceStart()
|
|
{
|
|
controller->resetContext();
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
|
for (const char *c : {"h", "i", ".", " "})
|
|
tap(makeKey(KeyType::Character, QString::fromLatin1(c)));
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
|
tap(makeKey(KeyType::Character, u"o"_s));
|
|
QCOMPARE(engine.output, u"Hi. O");
|
|
}
|
|
|
|
void noCapitalizationInPasswordFields()
|
|
{
|
|
TextContext context;
|
|
context.purpose = TextContext::Purpose::Password;
|
|
controller->setContext(context);
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
|
|
}
|
|
|
|
void fieldTextDrivesCapitalization()
|
|
{
|
|
TextContext context;
|
|
context.hasSurroundingText = true;
|
|
context.textBeforeCursor = u"Done! "_s;
|
|
controller->setContext(context);
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
|
context.textBeforeCursor = u"Done, "_s;
|
|
controller->setContext(context);
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
|
|
}
|
|
|
|
void doubleSpaceTypesPeriod()
|
|
{
|
|
const KeyDef space = makeKey(KeyType::Space);
|
|
tap(makeKey(KeyType::Character, u"a"_s));
|
|
tap(space);
|
|
tap(space);
|
|
QCOMPARE(engine.output, u"a. ");
|
|
QCOMPARE(engine.mismatchedReplacements, 0);
|
|
// A Backspace keysym may reach the client after the commit and eat the new space.
|
|
QVERIFY(engine.keysyms.isEmpty());
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
|
}
|
|
|
|
void doubleSpacePeriodCanBeTurnedOff()
|
|
{
|
|
controller->setOptions({.doubleSpacePeriod = false});
|
|
const KeyDef space = makeKey(KeyType::Space);
|
|
tap(makeKey(KeyType::Character, u"a"_s));
|
|
tap(space);
|
|
tap(space);
|
|
QCOMPARE(engine.output, u"A ");
|
|
}
|
|
|
|
void autoCapitalizationCanBeTurnedOff()
|
|
{
|
|
controller->resetContext();
|
|
controller->setOptions({.autoCapitalize = false});
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
|
|
tap(makeKey(KeyType::Character, u"a"_s));
|
|
QCOMPARE(engine.output, u"a");
|
|
}
|
|
|
|
void doubleTapShiftLocksAtSentenceStart()
|
|
{
|
|
controller->resetContext(); // a field was focused
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); // auto-shift
|
|
const KeyDef shift = makeKey(KeyType::Shift);
|
|
controller->keyDown(shift, 5000);
|
|
controller->keyUp(shift, KeyOutput::Primary, {}, 5050);
|
|
controller->keyDown(shift, 5200);
|
|
controller->keyUp(shift, KeyOutput::Primary, {}, 5250);
|
|
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
|
|
}
|
|
|
|
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 globeRequestsNextLayout()
|
|
{
|
|
int requested = 0;
|
|
connect(controller.get(), &KeyboardController::nextLayoutRequested, this, [&] { ++requested; });
|
|
tap(makeKey(KeyType::Globe));
|
|
QCOMPARE(requested, 1);
|
|
}
|
|
|
|
void keysymKeyActsOnPressAndRepeats()
|
|
{
|
|
KeyDef left = makeKey(KeyType::Keysym, u"←"_s);
|
|
left.keysym = XKB_KEY_Left;
|
|
left.repeat = true;
|
|
controller->keyDown(left, 0);
|
|
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_Left});
|
|
QTRY_VERIFY_WITH_TIMEOUT(engine.keysyms.size() >= 3, 2000); // held: repeats
|
|
controller->keyUp(left, KeyOutput::Primary, {}, 0);
|
|
const auto sent = engine.keysyms.size();
|
|
QTest::qWait(200);
|
|
QCOMPARE(engine.keysyms.size(), sent);
|
|
QVERIFY(engine.output.isEmpty()); // no text
|
|
}
|
|
|
|
void sectionsAreComposedAroundTheMainKeys()
|
|
{
|
|
const qsizetype mainKeys = model.rowCount();
|
|
const qreal mainUnits = model.totalUnits();
|
|
model.setVisibleSections({MainSectionId, u"function"_s});
|
|
QCOMPARE(model.rowCount(), mainKeys + 13);
|
|
QCOMPARE(model.totalUnits(), mainUnits); // above, not beside
|
|
model.setVisibleSections({MainSectionId, u"navigation"_s});
|
|
QCOMPARE(model.totalUnits(), mainUnits + 3);
|
|
model.setVisibleSections({u"numpad"_s});
|
|
QCOMPARE(model.rowCount(), 19);
|
|
model.setVisibleSections({});
|
|
QCOMPARE(model.rowCount(), mainKeys); // never nothing
|
|
}
|
|
|
|
void dismissEmitsSignal()
|
|
{
|
|
int dismissed = 0;
|
|
connect(controller.get(), &KeyboardController::dismissRequested, this, [&] { ++dismissed; });
|
|
tap(makeKey(KeyType::Dismiss));
|
|
QCOMPARE(dismissed, 1);
|
|
}
|
|
};
|
|
|
|
QTEST_GUILESS_MAIN(TestController)
|
|
#include "tst_controller.moc"
|