Layouts, iPad-style rendering, taps, shift and layers

- JSON layouts (en-US, sv-SE inheriting number/symbol layers via base)
- LayoutModel + KeyGeometry: proportional rows, nearest-key hit testing
- KeyboardSurface: multi-pointer touch/mouse routing to the controller
- KeyboardController + ShiftState: iOS shift/caps-lock, layers, globe, dismiss
- QML view with light/dark iPad palette and drag handle
- DevHooks: SLOPOSK_AUTOTYPE, SLOPOSK_SCREENSHOT for headless checks
- Unit tests for shift, loader, geometry and controller

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:50:21 +02:00
co-authored by Claude Opus 5.5
parent cdef9b4ad0
commit be14bbcc62
35 changed files with 1970 additions and 65 deletions
+11
View File
@@ -0,0 +1,11 @@
function(sloposk_add_test name)
qt_add_executable(${name} ${name}.cpp)
target_link_libraries(${name} PRIVATE sloposk_core Qt6::Test)
add_test(NAME ${name} COMMAND ${name})
set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endfunction()
sloposk_add_test(tst_shiftstate)
sloposk_add_test(tst_layoutloader)
sloposk_add_test(tst_keygeometry)
sloposk_add_test(tst_controller)
+128
View File
@@ -0,0 +1,128 @@
#include "engine/InputEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
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;
};
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;
LayoutRepository repo;
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 = {};
repo = {};
Q_INIT_RESOURCE(layouts);
repo.loadDirectory(u":/layouts"_s);
repo.finalize(u"en_US"_s);
controller = std::make_unique<KeyboardController>(engine, model, repo);
}
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 globeCyclesLayouts()
{
QCOMPARE(model.layoutName(), u"English (US)");
tap(makeKey(KeyType::Globe));
QCOMPARE(model.layoutName(), u"Svenska");
tap(makeKey(KeyType::Globe));
QCOMPARE(model.layoutName(), u"English (US)");
}
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"
+55
View File
@@ -0,0 +1,55 @@
#include "layout/KeyGeometry.h"
#include <QTest>
using namespace Qt::StringLiterals;
namespace {
KeyDef key(qreal width = 1.0, KeyType type = KeyType::Character)
{
KeyDef k;
k.type = type;
k.label = u"x"_s;
k.width = width;
return k;
}
} // namespace
class TestKeyGeometry : public QObject
{
Q_OBJECT
private Q_SLOTS:
void fillsWidthAndHeight()
{
const Layer layer{u"l"_s, {{key(), key()}, {key(2)}}};
const KeyboardMetrics metrics{.padding = 10, .gap = 10};
const auto rects = computeKeyRects(layer, {210, 110}, metrics);
QCOMPARE(rects.size(), 3);
QCOMPARE(rects[0], QRectF(10, 10, 90, 40));
QCOMPARE(rects[1], QRectF(110, 10, 90, 40));
QCOMPARE(rects[2], QRectF(10, 60, 190, 40)); // a 2-unit key spans the gap
}
void spacersShiftFollowingKeys()
{
const Layer layer{u"l"_s, {{key(), key(), key()}, {key(0.5, KeyType::Spacer), key(), key(1.5)}}};
const auto rects = computeKeyRects(layer, {300, 100}, {.padding = 0, .gap = 0});
QCOMPARE(rects[4].left(), 50.0);
QCOMPARE(rects[5].right(), 300.0);
}
void shorterRowsAreCentered()
{
const Layer layer{u"l"_s, {{key(), key(), key(), key()}, {key(), key()}}};
const auto rects = computeKeyRects(layer, {400, 100}, {.padding = 0, .gap = 0});
QCOMPARE(rects[4].left(), 100.0);
QCOMPARE(rects[5].right(), 300.0);
}
};
QTEST_GUILESS_MAIN(TestKeyGeometry)
#include "tst_keygeometry.moc"
+91
View File
@@ -0,0 +1,91 @@
#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
private Q_SLOTS:
void parsesStringAndObjectKeys()
{
QString error;
const auto layout = LayoutLoader::parse(R"({
"id": "t", "layers": [{"name": "letters", "rows": [[
"q",
{"label": "e", "flick": "3", "alternates": ["é", "è"]},
{"type": "backspace", "width": 1.5}
]]}]
})", &error);
QVERIFY2(layout, qPrintable(error));
const Row &row = layout->layers.first().rows.first();
QCOMPARE(row.size(), 3);
QCOMPARE(row[0].label, u"q");
QCOMPARE(row[1].flick, u"3");
QCOMPARE(row[1].alternates, QStringList({u"é"_s, u"è"_s}));
QCOMPARE(row[2].type, KeyType::Backspace);
QCOMPARE(row[2].width, 1.5);
}
void rejectsUnknownKeyType()
{
QString error;
QVERIFY(!LayoutLoader::parse(R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "warp"}]]}]})", &error));
QVERIFY(error.contains(u"warp"));
}
void rejectsMissingId()
{
QString error;
QVERIFY(!LayoutLoader::parse(R"({"layers": []})", &error));
}
void validateCatchesUnknownLayerTarget()
{
QString error;
const auto layout = LayoutLoader::parse(
R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "layer", "target": "nope"}]]}]})", &error);
QVERIFY(layout);
QVERIFY(!LayoutLoader::validate(*layout, &error));
}
void builtinLayoutsAreConsistent()
{
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);
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)));
}
}
}
}
};
QTEST_GUILESS_MAIN(TestLayoutLoader)
#include "tst_layoutloader.moc"
+86
View File
@@ -0,0 +1,86 @@
#include "input/ShiftState.h"
#include <QTest>
using Mode = ShiftState::Mode;
class TestShiftState : public QObject
{
Q_OBJECT
private Q_SLOTS:
void tapShiftsOneCharacter()
{
ShiftState shift;
shift.press(0);
shift.release();
QCOMPARE(shift.mode(), Mode::Once);
shift.characterTyped();
QCOMPARE(shift.mode(), Mode::Off);
}
void doubleTapLocks()
{
ShiftState shift;
shift.press(0);
shift.release();
shift.press(200);
shift.release();
QCOMPARE(shift.mode(), Mode::Locked);
shift.characterTyped();
shift.characterTyped();
QCOMPARE(shift.mode(), Mode::Locked);
}
void tapUnlocks()
{
ShiftState shift;
shift.press(0);
shift.press(100);
shift.press(1000);
QCOMPARE(shift.mode(), Mode::Off);
}
void slowSecondTapTurnsOff()
{
ShiftState shift;
shift.press(0);
shift.release();
shift.press(ShiftState::DoubleTapMs + 1);
shift.release();
QCOMPARE(shift.mode(), Mode::Off);
}
void holdShiftsEveryCharacterUntilRelease()
{
ShiftState shift;
shift.press(0);
shift.characterTyped();
shift.characterTyped();
QCOMPARE(shift.mode(), Mode::Once);
shift.release();
QCOMPARE(shift.mode(), Mode::Off);
}
void automaticIgnoredWhileLocked()
{
ShiftState shift;
shift.press(0);
shift.press(100);
shift.release();
shift.setAutomatic(false);
QCOMPARE(shift.mode(), Mode::Locked);
}
void automaticCapitalizes()
{
ShiftState shift;
shift.setAutomatic(true);
QCOMPARE(shift.mode(), Mode::Once);
shift.characterTyped();
QCOMPARE(shift.mode(), Mode::Off);
}
};
QTEST_GUILESS_MAIN(TestShiftState)
#include "tst_shiftstate.moc"