Fix double-space period, shift double-tap; settings; letters from system keymap
- Double-space replaced the space via a Backspace keysym, which clients apply after the commit: use delete_surrounding_text with the commit - Tests use a shared FakeEngine that applies ops to a simulated field - Double-tap shift locks at a sentence start; typing between taps doesn't - Organization name set: config lives in ~/.config/sloposk/ - Settings (auto-capitalization, double-space period) with a ⚙ panel - Letter rows come from the compositor's XKB keymap; other letters of the layout become long-press alternates; globe switches the system layout over org.kde.keyboard - One JSON template for non-letter layers; stretch keys even out rows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ endfunction()
|
||||
|
||||
sloposk_add_test(tst_shiftstate)
|
||||
sloposk_add_test(tst_layoutloader)
|
||||
sloposk_add_test(tst_layoutbuilder)
|
||||
sloposk_add_test(tst_keygeometry)
|
||||
sloposk_add_test(tst_controller)
|
||||
sloposk_add_test(tst_pointertracker)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "engine/InputEngine.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
// Records what the keyboard sends and applies it to a simulated field, so
|
||||
// tests check the text a user would see rather than the calls made.
|
||||
class FakeEngine : public InputEngine
|
||||
{
|
||||
public:
|
||||
void commitText(const QString &text) override { output += text; }
|
||||
|
||||
void replaceBeforeCursor(const QString &before, const QString &text) override
|
||||
{
|
||||
if (output.endsWith(before))
|
||||
output.chop(before.size());
|
||||
else
|
||||
mismatchedReplacements++;
|
||||
output += text;
|
||||
}
|
||||
|
||||
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
|
||||
{
|
||||
keysyms.append(keysym);
|
||||
lastModifiers = modifiers;
|
||||
}
|
||||
|
||||
QString output; // the field's text
|
||||
int mismatchedReplacements = 0; // replaced text that wasn't before the cursor
|
||||
QList<quint32> keysyms;
|
||||
Qt::KeyboardModifiers lastModifiers;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include "layout/KeymapLetters.h"
|
||||
#include "layout/LayoutBuilder.h"
|
||||
#include "layout/LayoutLoader.h"
|
||||
|
||||
#include <QTest>
|
||||
#include <xkbcommon/xkbcommon.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
// Builds layouts the way the app does: the built-in template filled from a
|
||||
// keymap, here compiled from XKB names instead of received from a compositor.
|
||||
// Q_INIT_RESOURCE can't be used inside a namespace.
|
||||
inline void initLayoutResources() { Q_INIT_RESOURCE(layouts); }
|
||||
|
||||
namespace TestLayouts {
|
||||
|
||||
inline Layout builtinTemplate()
|
||||
{
|
||||
initLayoutResources();
|
||||
QString error;
|
||||
const auto layout = LayoutLoader::load(QStringLiteral(":/layouts/template.json"), &error);
|
||||
if (!layout)
|
||||
qFatal("template.json: %s", qPrintable(error));
|
||||
return *layout;
|
||||
}
|
||||
|
||||
inline KeymapLetters letters(const char *layout, const char *variant = nullptr)
|
||||
{
|
||||
const std::unique_ptr<xkb_context, void (*)(xkb_context *)> context(
|
||||
xkb_context_new(XKB_CONTEXT_NO_ENVIRONMENT_NAMES), xkb_context_unref);
|
||||
const xkb_rule_names names{.rules = "evdev", .model = "pc105", .layout = layout, .variant = variant,
|
||||
.options = nullptr};
|
||||
const std::unique_ptr<xkb_keymap, void (*)(xkb_keymap *)> keymap(
|
||||
xkb_keymap_new_from_names(context.get(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref);
|
||||
if (!keymap)
|
||||
qFatal("no XKB keymap for %s", layout);
|
||||
return readKeymapLetters(keymap.get(), 0);
|
||||
}
|
||||
|
||||
inline Layout build(const char *layout, const char *variant = nullptr)
|
||||
{
|
||||
return buildLayout(builtinTemplate(), letters(layout, variant));
|
||||
}
|
||||
|
||||
} // namespace TestLayouts
|
||||
+43
-30
@@ -1,7 +1,7 @@
|
||||
#include "engine/InputEngine.h"
|
||||
#include "FakeEngine.h"
|
||||
#include "input/KeyboardController.h"
|
||||
#include "layout/LayoutModel.h"
|
||||
#include "layout/LayoutRepository.h"
|
||||
#include "TestLayouts.h"
|
||||
|
||||
#include <QTest>
|
||||
#include <xkbcommon/xkbcommon-keysyms.h>
|
||||
@@ -10,21 +10,6 @@ using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeEngine : public InputEngine
|
||||
{
|
||||
public:
|
||||
void commitText(const QString &text) override { output += text; }
|
||||
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
|
||||
{
|
||||
keysyms.append(keysym);
|
||||
lastModifiers = modifiers;
|
||||
}
|
||||
|
||||
QString output;
|
||||
QList<quint32> keysyms;
|
||||
Qt::KeyboardModifiers lastModifiers;
|
||||
};
|
||||
|
||||
KeyDef makeKey(KeyType type, const QString &label = {})
|
||||
{
|
||||
KeyDef k;
|
||||
@@ -41,7 +26,6 @@ class TestController : public QObject
|
||||
|
||||
private:
|
||||
FakeEngine engine;
|
||||
LayoutRepository repo;
|
||||
LayoutModel model;
|
||||
std::unique_ptr<KeyboardController> controller;
|
||||
qint64 now = 0;
|
||||
@@ -57,11 +41,8 @@ private Q_SLOTS:
|
||||
void init()
|
||||
{
|
||||
engine = {};
|
||||
repo = {};
|
||||
Q_INIT_RESOURCE(layouts);
|
||||
repo.loadDirectory(u":/layouts"_s);
|
||||
repo.finalize(u"en_US"_s);
|
||||
controller = std::make_unique<KeyboardController>(engine, model, repo);
|
||||
model.setLayout(TestLayouts::build("us"));
|
||||
controller = std::make_unique<KeyboardController>(engine, model);
|
||||
}
|
||||
|
||||
void typesLowercase()
|
||||
@@ -156,11 +137,44 @@ private Q_SLOTS:
|
||||
tap(makeKey(KeyType::Character, u"a"_s));
|
||||
tap(space);
|
||||
tap(space);
|
||||
QCOMPARE(engine.output, u"a . ");
|
||||
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_BackSpace}); // removed the first 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);
|
||||
@@ -177,13 +191,12 @@ private Q_SLOTS:
|
||||
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Up}));
|
||||
}
|
||||
|
||||
void globeCyclesLayouts()
|
||||
void globeRequestsNextLayout()
|
||||
{
|
||||
QCOMPARE(model.layoutName(), u"English (US)");
|
||||
int requested = 0;
|
||||
connect(controller.get(), &KeyboardController::nextLayoutRequested, this, [&] { ++requested; });
|
||||
tap(makeKey(KeyType::Globe));
|
||||
QCOMPARE(model.layoutName(), u"Svenska");
|
||||
tap(makeKey(KeyType::Globe));
|
||||
QCOMPARE(model.layoutName(), u"English (US)");
|
||||
QCOMPARE(requested, 1);
|
||||
}
|
||||
|
||||
void dismissEmitsSignal()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 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 "FakeEngine.h"
|
||||
#include "input/KeyboardController.h"
|
||||
#include "input/KeyboardSurface.h"
|
||||
#include "layout/LayoutModel.h"
|
||||
#include "layout/LayoutRepository.h"
|
||||
#include "TestLayouts.h"
|
||||
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlEngine>
|
||||
@@ -21,21 +21,6 @@ using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeEngine : public InputEngine
|
||||
{
|
||||
public:
|
||||
void commitText(const QString &text) override { output += text; }
|
||||
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
|
||||
{
|
||||
keysyms.append(keysym);
|
||||
lastModifiers = modifiers;
|
||||
}
|
||||
|
||||
QString output;
|
||||
QList<quint32> keysyms;
|
||||
Qt::KeyboardModifiers lastModifiers;
|
||||
};
|
||||
|
||||
constexpr QPoint KeyboardOffset(0, 100); // room above for popups
|
||||
constexpr int LongPressWaitMs = 600;
|
||||
|
||||
@@ -47,7 +32,6 @@ class TestKeyboardSurface : public QObject
|
||||
|
||||
private:
|
||||
FakeEngine engine;
|
||||
LayoutRepository repo;
|
||||
LayoutModel model;
|
||||
std::unique_ptr<KeyboardController> controller;
|
||||
QQmlEngine qml;
|
||||
@@ -81,10 +65,8 @@ private:
|
||||
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);
|
||||
model.setLayout(TestLayouts::build("us"));
|
||||
controller = std::make_unique<KeyboardController>(engine, model);
|
||||
|
||||
QQmlComponent component(&qml, "Sloposk", "Keyboard");
|
||||
keyboard = qobject_cast<QQuickItem *>(component.createWithInitialProperties({
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "TestLayouts.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
|
||||
QString rowLetters(const Layout &layout, int row)
|
||||
{
|
||||
QString letters;
|
||||
for (const KeyDef &key : layout.layers.first().rows[row]) {
|
||||
if (key.type == KeyType::Character && key.label.front().isLetter())
|
||||
letters += key.label;
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
const KeyDef &letterKey(const Layout &layout, const QString &label)
|
||||
{
|
||||
for (const Row &row : layout.layers.first().rows) {
|
||||
for (const KeyDef &key : row) {
|
||||
if (key.label == label)
|
||||
return key;
|
||||
}
|
||||
}
|
||||
qFatal("no key %s", qPrintable(label));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestLayoutBuilder : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void readsLetterRowsFromKeymap()
|
||||
{
|
||||
const Layout us = TestLayouts::build("us");
|
||||
QCOMPARE(rowLetters(us, 0), u"qwertyuiop");
|
||||
QCOMPARE(rowLetters(us, 1), u"asdfghjkl");
|
||||
QCOMPARE(rowLetters(us, 2), u"zxcvbnm");
|
||||
|
||||
const Layout sk = TestLayouts::build("sk");
|
||||
QCOMPARE(sk.name, u"Slovak");
|
||||
QCOMPARE(rowLetters(sk, 0), u"qwertzuiopúä");
|
||||
QCOMPARE(rowLetters(sk, 1), u"asdfghjklô");
|
||||
QCOMPARE(rowLetters(sk, 2), u"yxcvbnm");
|
||||
|
||||
const Layout se = TestLayouts::build("se");
|
||||
QCOMPARE(rowLetters(se, 0), u"qwertyuiopå");
|
||||
QCOMPARE(rowLetters(se, 1), u"asdfghjklöä");
|
||||
}
|
||||
|
||||
void shiftedFormComesFromKeymap()
|
||||
{
|
||||
QCOMPARE(letterKey(TestLayouts::build("sk"), u"ô"_s).shifted, u"Ô");
|
||||
}
|
||||
|
||||
void flicksFollowColumns()
|
||||
{
|
||||
const Layout sk = TestLayouts::build("sk");
|
||||
QCOMPARE(letterKey(sk, u"q"_s).flick, u"1");
|
||||
QCOMPARE(letterKey(sk, u"p"_s).flick, u"0");
|
||||
QVERIFY(letterKey(sk, u"ú"_s).flick.isEmpty()); // more letters than flicks
|
||||
}
|
||||
|
||||
void numberRowLettersBecomeAlternates()
|
||||
{
|
||||
const Layout sk = TestLayouts::build("sk");
|
||||
// The keymap's own letters come first: they're what this language types.
|
||||
QCOMPARE(letterKey(sk, u"s"_s).alternates.first(), u"š");
|
||||
QCOMPARE(letterKey(sk, u"l"_s).alternates.first(), u"ľ");
|
||||
QCOMPARE(letterKey(sk, u"n"_s).alternates.first(), u"ň"); // BKSL on sk
|
||||
QCOMPARE(letterKey(sk, u"e"_s).alternates.first(), u"é");
|
||||
QCOMPARE(letterKey(sk, u"y"_s).alternates.first(), u"ý");
|
||||
}
|
||||
|
||||
void lettersWithoutDecompositionFindTheirBase()
|
||||
{
|
||||
QCOMPARE(letterKey(TestLayouts::build("de"), u"s"_s).alternates.first(), u"ß");
|
||||
}
|
||||
|
||||
void alternatesSkipLettersThatHaveKeys()
|
||||
{
|
||||
const Layout se = TestLayouts::build("se");
|
||||
const QStringList a = letterKey(se, u"a"_s).alternates;
|
||||
QVERIFY(!a.contains(u"ä"_s));
|
||||
QVERIFY(!a.contains(u"å"_s));
|
||||
QVERIFY(a.contains(u"á"_s));
|
||||
}
|
||||
|
||||
void noPlaceholdersRemain()
|
||||
{
|
||||
for (const Layer &layer : TestLayouts::build("us").layers) {
|
||||
for (const Row &row : layer.rows) {
|
||||
for (const KeyDef &key : row)
|
||||
QVERIFY(key.type != KeyType::Letters);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestLayoutBuilder)
|
||||
#include "tst_layoutbuilder.moc"
|
||||
+29
-29
@@ -1,22 +1,11 @@
|
||||
#include "TestLayouts.h"
|
||||
#include "layout/KeyGeometry.h"
|
||||
#include "layout/LayoutLoader.h"
|
||||
#include "layout/LayoutRepository.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
using namespace Qt::StringLiterals;
|
||||
|
||||
namespace {
|
||||
|
||||
qreal rowUnits(const Row &row)
|
||||
{
|
||||
qreal units = 0;
|
||||
for (const KeyDef &key : row)
|
||||
units += key.width;
|
||||
return units;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestLayoutLoader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -65,24 +54,35 @@ private Q_SLOTS:
|
||||
QVERIFY(!LayoutLoader::validate(*layout, &error));
|
||||
}
|
||||
|
||||
void builtinLayoutsAreConsistent()
|
||||
void templateIsValid()
|
||||
{
|
||||
Q_INIT_RESOURCE(layouts);
|
||||
LayoutRepository repo;
|
||||
repo.loadDirectory(u":/layouts"_s);
|
||||
repo.finalize({});
|
||||
QVERIFY(repo.indexOf(u"en-US"_s) >= 0);
|
||||
QVERIFY(repo.indexOf(u"sv-SE"_s) >= 0);
|
||||
QString error;
|
||||
QVERIFY2(LayoutLoader::validate(TestLayouts::builtinTemplate(), &error), qPrintable(error));
|
||||
}
|
||||
|
||||
for (const Layout &layout : repo.layouts()) {
|
||||
QVERIFY2(layout.layer(u"numbers"_s), qPrintable(layout.id)); // sv-SE gets it from its base
|
||||
for (const Layer &layer : layout.layers) {
|
||||
const qreal expected = rowUnits(layer.rows.first());
|
||||
for (const Row &row : layer.rows) {
|
||||
QVERIFY2(qFuzzyCompare(rowUnits(row), expected),
|
||||
qPrintable(u"%1/%2: rows differ in width"_s.arg(layout.id, layer.name)));
|
||||
}
|
||||
}
|
||||
void rejectsLettersWithoutRow()
|
||||
{
|
||||
QString error;
|
||||
QVERIFY(!LayoutLoader::parse(R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "letters"}]]}]})",
|
||||
&error));
|
||||
}
|
||||
|
||||
// Stretching must even out rows whatever the keymap's letter counts.
|
||||
void builtLayersHaveEqualRows_data()
|
||||
{
|
||||
QTest::addColumn<QString>("xkbLayout");
|
||||
for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"})
|
||||
QTest::newRow(layout) << QString::fromLatin1(layout);
|
||||
}
|
||||
|
||||
void builtLayersHaveEqualRows()
|
||||
{
|
||||
QFETCH(QString, xkbLayout);
|
||||
const Layout layout = TestLayouts::build(qPrintable(xkbLayout));
|
||||
for (const Layer &layer : layout.layers) {
|
||||
const qreal expected = rowUnits(layer.rows.first());
|
||||
for (const Row &row : layer.rows)
|
||||
QVERIFY2(qFuzzyCompare(rowUnits(row), expected), qPrintable(layer.name));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,37 @@ private Q_SLOTS:
|
||||
QCOMPARE(shift.mode(), Mode::Locked);
|
||||
}
|
||||
|
||||
void doubleTapLocksAfterAutomaticShift()
|
||||
{
|
||||
ShiftState shift;
|
||||
shift.setAutomatic(true);
|
||||
shift.press(0); // turns auto-shift off...
|
||||
shift.release();
|
||||
shift.press(200); // ...and the second tap locks
|
||||
shift.release();
|
||||
QCOMPARE(shift.mode(), Mode::Locked);
|
||||
}
|
||||
|
||||
void tapAfterUnlockIsNotDoubleTap()
|
||||
{
|
||||
ShiftState shift;
|
||||
shift.press(0);
|
||||
shift.press(100); // locked
|
||||
shift.press(1000); // unlocks
|
||||
shift.press(1100);
|
||||
QCOMPARE(shift.mode(), Mode::Once);
|
||||
}
|
||||
|
||||
void typingBetweenTapsIsNotDoubleTap()
|
||||
{
|
||||
ShiftState shift;
|
||||
shift.press(0);
|
||||
shift.release();
|
||||
shift.characterTyped();
|
||||
shift.press(200);
|
||||
QCOMPARE(shift.mode(), Mode::Once);
|
||||
}
|
||||
|
||||
void tapUnlocks()
|
||||
{
|
||||
ShiftState shift;
|
||||
|
||||
Reference in New Issue
Block a user