Full keyboard mode, Ctrl/Alt/Super, dead keys, shift levels

- Full (iPad Pro) mode: every typing key from the keymap, number row,
  tab, caps lock, ctrl/super/alt; shifted symbol shown above and flickable
- Dead keys compose with the locale's XKB compose table
- Ctrl/Alt/Super stick like shift and turn keys into shortcuts; a tapped
  or held shift joins shortcuts and non-text keys, auto-shift and caps
  lock don't
- Auto-capitalization and caps lock only capitalize letters; the user's
  shift picks the keymap's shifted level
- Mode switch in settings keeps key size by resizing the window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:29:24 +02:00
co-authored by Claude Opus 5.5
parent 88338fde22
commit ba2f17d665
36 changed files with 948 additions and 202 deletions
+1
View File
@@ -13,6 +13,7 @@ sloposk_add_test(tst_controller)
sloposk_add_test(tst_pointertracker)
sloposk_add_test(tst_alternatespicker)
sloposk_add_test(tst_textrules)
sloposk_add_test(tst_deadkeys)
sloposk_add_test(tst_keyrepeater)
sloposk_add_test(tst_visibility)
+5 -5
View File
@@ -1,6 +1,6 @@
#pragma once
#include "layout/KeymapLetters.h"
#include "layout/KeymapKeys.h"
#include "layout/LayoutBuilder.h"
#include "layout/LayoutLoader.h"
@@ -26,7 +26,7 @@ inline Layout builtinTemplate()
return *layout;
}
inline KeymapLetters letters(const char *layout, const char *variant = nullptr)
inline KeymapKeys keymapKeys(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);
@@ -36,12 +36,12 @@ inline KeymapLetters letters(const char *layout, const char *variant = nullptr)
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);
return readKeymapKeys(keymap.get(), 0);
}
inline Layout build(const char *layout, const char *variant = nullptr)
inline Layout build(const char *layout, KeyboardMode mode = KeyboardMode::Compact)
{
return buildLayout(builtinTemplate(), letters(layout, variant));
return buildLayout(builtinTemplate(), keymapKeys(layout), mode);
}
} // namespace TestLayouts
+95
View File
@@ -230,6 +230,101 @@ private Q_SLOTS:
QCOMPARE(model.rowCount(), mainKeys); // never nothing
}
KeyDef keysymKey(quint32 keysym)
{
KeyDef key = makeKey(KeyType::Keysym);
key.keysym = keysym;
return key;
}
KeyDef modifierKey(const QString &name)
{
KeyDef key = makeKey(KeyType::Modifier);
key.modifier = name;
return key;
}
void ctrlTurnsKeysIntoShortcuts()
{
tap(modifierKey(u"ctrl"_s));
QCOMPARE(controller->modifierModes().value(u"ctrl"_s).toInt(), int(KeyboardController::ShiftMode::Once));
tap(makeKey(KeyType::Character, u"c"_s));
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_c});
QCOMPARE(engine.lastModifiers, Qt::ControlModifier);
QVERIFY(engine.output.isEmpty());
// One-shot: the next key types again.
tap(makeKey(KeyType::Character, u"c"_s));
QCOMPARE(engine.output, u"c");
}
void heldModifierAppliesToEveryKey()
{
const KeyDef alt = modifierKey(u"alt"_s);
controller->keyDown(alt, 0);
tap(keysymKey(XKB_KEY_Tab));
tap(keysymKey(XKB_KEY_Tab));
controller->keyUp(alt, KeyOutput::Primary, {}, 9000);
QCOMPARE(engine.keysyms.size(), 2);
QCOMPARE(engine.lastModifiers, Qt::AltModifier);
QCOMPARE(controller->modifierModes().value(u"alt"_s).toInt(), int(KeyboardController::ShiftMode::Off));
}
void tappedShiftAppliesToFunctionKeys()
{
tap(makeKey(KeyType::Character, u"a"_s)); // mid-sentence: no auto-shift
tap(makeKey(KeyType::Shift));
tap(keysymKey(XKB_KEY_F5));
QCOMPARE(engine.keysyms.last(), quint32(XKB_KEY_F5));
QCOMPARE(engine.lastModifiers, Qt::ShiftModifier);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); // used up
}
void autoShiftAndCapsLockDontApplyToFunctionKeys()
{
controller->resetContext(); // sentence start: auto-shift on
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
tap(keysymKey(XKB_KEY_Left));
QCOMPARE(engine.lastModifiers, Qt::NoModifier);
tap(makeKey(KeyType::CapsLock));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
tap(keysymKey(XKB_KEY_Left));
QCOMPARE(engine.lastModifiers, Qt::NoModifier);
tap(makeKey(KeyType::Character, u"a"_s));
tap(makeKey(KeyType::Character, u"b"_s));
QCOMPARE(engine.output, u"AB");
tap(makeKey(KeyType::CapsLock));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void autoShiftCapitalizesLettersOnly()
{
KeyDef lj = makeKey(KeyType::Character, u"ľ"_s); // sk number row: Shift+ľ = 2
lj.shifted = u"2"_s;
KeyDef one = makeKey(KeyType::Character, u"+"_s);
one.shifted = u"1"_s;
controller->resetContext(); // sentence start
tap(one);
QCOMPARE(engine.output, u"+"); // not the shifted level
controller->resetContext();
tap(lj);
QCOMPARE(engine.output, u"+Ľ");
tap(makeKey(KeyType::Shift)); // the user's shift does pick it
tap(lj);
QCOMPARE(engine.output, u"+Ľ2");
}
void deadKeyComposesWithNextCharacter()
{
KeyDef acute = makeKey(KeyType::Character, u"´"_s);
acute.dead = XKB_KEY_dead_acute;
tap(makeKey(KeyType::Character, u"x"_s));
tap(acute);
QVERIFY(engine.output == u"x"); // nothing typed yet
tap(makeKey(KeyType::Character, u"e"_s));
QVERIFY(engine.output.endsWith(u"é"_s));
}
void dismissEmitsSignal()
{
int dismissed = 0;
+43
View File
@@ -0,0 +1,43 @@
#include "input/DeadKeys.h"
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
using namespace Qt::StringLiterals;
class TestDeadKeys : public QObject
{
Q_OBJECT
private Q_SLOTS:
void composesWithTheNextLetter()
{
DeadKeys dead(u"en_US.UTF-8"_s);
QVERIFY(!dead.isPending());
dead.press(XKB_KEY_dead_acute);
QVERIFY(dead.isPending());
QCOMPARE(dead.compose(u"e"_s), u"é");
QVERIFY(!dead.isPending());
dead.press(XKB_KEY_dead_caron);
QCOMPARE(dead.compose(u"C"_s), u"Č");
}
void typesAccentAndLetterWhenNothingComposes()
{
DeadKeys dead(u"en_US.UTF-8"_s);
dead.press(XKB_KEY_dead_caron);
QCOMPARE(dead.compose(u"q"_s), u"ˇq");
}
void cancelForgetsTheAccent()
{
DeadKeys dead(u"en_US.UTF-8"_s);
dead.press(XKB_KEY_dead_acute);
dead.cancel();
QVERIFY(!dead.isPending());
}
};
QTEST_GUILESS_MAIN(TestDeadKeys)
#include "tst_deadkeys.moc"
+32
View File
@@ -1,6 +1,7 @@
#include "TestLayouts.h"
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
using namespace Qt::StringLiterals;
@@ -90,6 +91,37 @@ private Q_SLOTS:
QVERIFY(a.contains(u"á"_s));
}
void fullModeHasEveryTypingKey()
{
const Layout us = TestLayouts::build("us", KeyboardMode::Full);
const auto labels = [&](int row) {
QString text;
for (const KeyDef &key : us.layers.first().rows[row]) {
if (key.type == KeyType::Character)
text += key.label;
}
return text;
};
QCOMPARE(labels(0), u"`1234567890-=");
QCOMPARE(labels(1), u"qwertyuiop[]\\");
QCOMPARE(labels(2), u"asdfghjkl;'");
QCOMPARE(labels(3), u"zxcvbnm,./"); // LSGT (< >) repeats , . shifted: left out
QCOMPARE(letterKey(us, u"1"_s).flick, u"!");
QCOMPARE(letterKey(us, u"1"_s).shifted, u"!");
}
void fullModeKeepsDeadKeysAndKeymapShift()
{
const Layout sk = TestLayouts::build("sk", KeyboardMode::Full);
const KeyDef &acute = letterKey(sk, u"´"_s);
QCOMPARE(acute.dead, quint32(XKB_KEY_dead_acute));
QCOMPARE(acute.deadShifted, quint32(XKB_KEY_dead_caron));
QCOMPARE(letterKey(sk, u"ô"_s).shifted, u"\""); // as the physical key
QCOMPARE(letterKey(sk, u"ľ"_s).shifted, u"2");
// ľ has its own key now, so it's not a long-press choice on l.
QVERIFY(!letterKey(sk, u"l"_s).alternates.contains(u"ľ"_s));
}
void noPlaceholdersRemain()
{
for (const Layer &layer : TestLayouts::build("us").layers) {
+7 -3
View File
@@ -72,14 +72,18 @@ private Q_SLOTS:
void builtLayersHaveEvenRows_data()
{
QTest::addColumn<QString>("xkbLayout");
for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"})
QTest::newRow(layout) << QString::fromLatin1(layout);
QTest::addColumn<KeyboardMode>("mode");
for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"}) {
QTest::addRow("%s compact", layout) << QString::fromLatin1(layout) << KeyboardMode::Compact;
QTest::addRow("%s full", layout) << QString::fromLatin1(layout) << KeyboardMode::Full;
}
}
void builtLayersHaveEvenRows()
{
QFETCH(QString, xkbLayout);
const Layout layout = TestLayouts::build(qPrintable(xkbLayout));
QFETCH(KeyboardMode, mode);
const Layout layout = TestLayouts::build(qPrintable(xkbLayout), mode);
for (const Layer &layer : layout.layers) {
const QList<QRectF> rects = computeKeyRects(layer, {1000, 300});
qsizetype i = 0;