Hold Keys mode: keys stay down while touched, like a hardware keyboard

- HardwareKeys presses a key's unshifted keysym on touch and releases it
  on lift, through fake input; held keys repeat and combine as on a
  physical keyboard (games, Shift+click)
- Shift/Ctrl/Alt/Super are plain held keys, caps lock is the system's,
  dead keys compose in the app
- No long-press, flick, trackpad or rollover in this mode; nothing is
  left held when the mode is turned off or touches are cancelled
- Settings switch; default stays the touch (virtual keyboard) mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:17:11 +02:00
co-authored by Claude Opus 5.5
parent f816135162
commit e02b1a8237
24 changed files with 319 additions and 6 deletions
+1
View File
@@ -8,6 +8,7 @@ qt_add_library(sloposk_core STATIC
input/ShiftState.h input/ShiftState.cpp
input/Modifiers.h input/Modifiers.cpp
input/DeadKeys.h input/DeadKeys.cpp
input/HardwareKeys.h input/HardwareKeys.cpp
input/PointerTracker.h input/PointerTracker.cpp
input/AlternatesPicker.h input/AlternatesPicker.cpp
input/KeyRepeater.h input/KeyRepeater.cpp
+6
View File
@@ -53,6 +53,12 @@ void KWinFakeInput::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers)
}
}
void KWinFakeInput::setKeyPressed(quint32 keysym, bool pressed)
{
if (isActive())
keyboard_keysym(keysym, pressed ? Pressed : Released);
}
void KWinFakeInput::typeText(const QString &text)
{
for (const char32_t ucs : text.toUcs4())
+3
View File
@@ -19,6 +19,9 @@ public:
// Presses and releases `keysym` with `modifiers` held. KWin finds the key
// (and any shift level) in the current keymap.
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier);
// Presses or releases one key, for keys held as long as the finger is
// down. Modifiers are keysyms too (Shift_L...): KWin holds the real key.
void setKeyPressed(quint32 keysym, bool pressed);
// Types each character as its keysym.
void typeText(const QString &text);
};
+7
View File
@@ -78,6 +78,13 @@ void KWinInputMethod::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers
m_fakeInput.sendKeysym(keysym, modifiers);
}
// Always fake input, even in a text field: held keys are key events, and the
// input method's keysyms can't hold a modifier across keys.
void KWinInputMethod::setKeyPressed(quint32 keysym, bool pressed)
{
m_fakeInput.setKeyPressed(keysym, pressed);
}
void KWinInputMethod::activate(::zwp_input_method_context_v1 *context)
{
m_context = std::make_unique<KWinInputContext>(context);
+1
View File
@@ -50,6 +50,7 @@ public:
void replaceBeforeCursor(const QString &before, const QString &text) override;
void setPreedit(const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
void setKeyPressed(quint32 keysym, bool pressed) override;
Q_SIGNALS:
void availableChanged();
+3
View File
@@ -29,6 +29,9 @@ public:
virtual void replaceBeforeCursor(const QString &before, const QString &text) = 0;
Q_INVOKABLE virtual void setPreedit(const QString &text) = 0;
Q_INVOKABLE virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
// Presses or releases one key, held in between like a hardware key, to
// the focused window. Modifiers are keys too (Shift_L, Control_L...).
virtual void setKeyPressed(quint32 keysym, bool pressed) = 0;
Q_SIGNALS:
void activeChanged();
+5
View File
@@ -21,3 +21,8 @@ void DirectEngine::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers)
{
m_backend.sendKeysym(keysym, modifiers);
}
void DirectEngine::setKeyPressed(quint32 keysym, bool pressed)
{
m_backend.setKeyPressed(keysym, pressed);
}
+1
View File
@@ -12,6 +12,7 @@ public:
void commitText(const QString &text) override;
void replaceBeforeCursor(const QString &before, const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
void setKeyPressed(quint32 keysym, bool pressed) override;
private:
TextBackend &m_backend;
+2
View File
@@ -15,4 +15,6 @@ public:
// See TextBackend::replaceBeforeCursor.
virtual void replaceBeforeCursor(const QString &before, const QString &text) = 0;
virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
// See TextBackend::setKeyPressed.
virtual void setKeyPressed(quint32 keysym, bool pressed) = 0;
};
+98
View File
@@ -0,0 +1,98 @@
#include "HardwareKeys.h"
#include "engine/InputEngine.h"
#include <xkbcommon/xkbcommon.h>
namespace {
quint32 modifierKeysym(const QString &name)
{
if (name == u"ctrl")
return XKB_KEY_Control_L;
if (name == u"alt")
return XKB_KEY_Alt_L;
return XKB_KEY_Super_L;
}
} // namespace
HardwareKeys::HardwareKeys(InputEngine &engine)
: m_engine(engine)
{
}
bool HardwareKeys::handles(const KeyDef &key)
{
return keysymOf(key) != XKB_KEY_NoSymbol;
}
// The key's unshifted symbol: a held Shift (or caps lock) makes the app pick
// the shifted one, as with a hardware key.
quint32 HardwareKeys::keysymOf(const KeyDef &key)
{
switch (key.type) {
case KeyType::Character:
return key.dead ? key.dead : xkb_utf32_to_keysym(key.label.toUcs4().value(0));
case KeyType::Space: return XKB_KEY_space;
case KeyType::Backspace: return XKB_KEY_BackSpace;
case KeyType::Enter: return XKB_KEY_Return;
case KeyType::Shift: return XKB_KEY_Shift_L;
case KeyType::CapsLock: return XKB_KEY_Caps_Lock;
case KeyType::Modifier: return modifierKeysym(key.modifier);
case KeyType::Keysym: return key.keysym;
default: return XKB_KEY_NoSymbol;
}
}
void HardwareKeys::press(const KeyDef &key)
{
const quint32 keysym = keysymOf(key);
if (keysym == XKB_KEY_NoSymbol)
return;
if (m_down[keysym]++ == 0)
m_engine.setKeyPressed(keysym, true);
if (key.type == KeyType::CapsLock)
m_capsLock = !m_capsLock;
}
void HardwareKeys::release(const KeyDef &key)
{
const quint32 keysym = keysymOf(key);
const auto it = m_down.find(keysym);
if (it == m_down.end())
return;
if (--*it == 0) {
m_down.erase(it);
m_engine.setKeyPressed(keysym, false);
}
}
void HardwareKeys::releaseAll()
{
for (auto it = m_down.cbegin(); it != m_down.cend(); ++it)
m_engine.setKeyPressed(it.key(), false);
m_down.clear();
}
bool HardwareKeys::isShiftHeld() const
{
return m_down.contains(XKB_KEY_Shift_L);
}
ShiftLevel HardwareKeys::shiftLevel() const
{
if (isShiftHeld())
return ShiftLevel::Shifted;
return m_capsLock ? ShiftLevel::Capitals : ShiftLevel::None;
}
QVariantMap HardwareKeys::modifierModes() const
{
QVariantMap modes;
for (const char *name : {"ctrl", "alt", "super"}) {
const QString key = QString::fromLatin1(name);
modes.insert(key, m_down.contains(modifierKeysym(key)) ? 1 : 0);
}
return modes;
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "layout/Layout.h"
#include <QHash>
#include <QVariantMap>
class InputEngine;
// The "hold keys" mode: the keyboard acts like a hardware one. A key is down
// while the finger is, so apps see held keys (games, Shift+click, the
// system's key repeat). Shift, Ctrl, Alt and Super are plain keys, caps lock
// is the system's, and dead keys compose in the app; none of the touch
// conveniences (sticky shift, auto-capitalization, accents) apply.
class HardwareKeys
{
public:
explicit HardwareKeys(InputEngine &engine);
// True if `key` is one this mode presses (not a layer, globe or dismiss key).
static bool handles(const KeyDef &key);
void press(const KeyDef &key);
void release(const KeyDef &key);
// Lets go of everything still down, e.g. when the mode is turned off.
void releaseAll();
// For the view: shift shown while held, caps lock while on.
ShiftLevel shiftLevel() const;
bool isShiftHeld() const;
bool isCapsLockOn() const { return m_capsLock; }
// "ctrl"/"alt"/"super" → 1 while held (ShiftState::Mode::Once), else 0.
QVariantMap modifierModes() const;
private:
static quint32 keysymOf(const KeyDef &key);
InputEngine &m_engine;
QHash<quint32, int> m_down; // keysym → fingers holding it
bool m_capsLock = false;
};
+36
View File
@@ -22,6 +22,7 @@ KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
: QObject(parent)
, m_engine(engine)
, m_model(model)
, m_hardware(engine)
, m_keysymRepeater(KeysymRepeat)
{
connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward);
@@ -30,11 +31,34 @@ KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
KeyboardController::ShiftMode KeyboardController::shiftMode() const
{
if (m_options.holdKeys) {
if (m_hardware.isCapsLockOn())
return ShiftMode::Locked;
return m_hardware.isShiftHeld() ? ShiftMode::Once : ShiftMode::Off;
}
return ShiftMode(m_shift.mode());
}
QVariantMap KeyboardController::modifierModes() const
{
return m_options.holdKeys ? m_hardware.modifierModes() : m_modifiers.modes();
}
bool KeyboardController::holdKey(const KeyDef &key, bool pressed)
{
if (!m_options.holdKeys || !HardwareKeys::handles(key))
return false;
pressed ? m_hardware.press(key) : m_hardware.release(key);
m_model.setShiftLevel(shiftLevel());
Q_EMIT shiftChanged();
Q_EMIT modifiersChanged();
return true;
}
void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs)
{
if (holdKey(key, true))
return;
switch (key.type) {
case KeyType::Shift:
changeShift([&] { m_shift.press(timeMs); });
@@ -72,6 +96,8 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs)
void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64)
{
if (holdKey(key, false))
return;
switch (key.type) {
case KeyType::Shift:
changeShift([&] { m_shift.release(); });
@@ -112,6 +138,8 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin
void KeyboardController::keyCancelled(const KeyDef &key)
{
if (holdKey(key, false))
return;
if (key.type == KeyType::Shift)
changeShift([&] { m_shift.release(); });
else if (key.type == KeyType::Modifier) {
@@ -125,6 +153,7 @@ void KeyboardController::keyCancelled(const KeyDef &key)
void KeyboardController::slideReleased(const KeyDef &target, qint64 timeMs)
{
holdKey(target, true); // only in hold-keys mode: the slide never pressed it
keyUp(target, KeyOutput::Primary, {}, timeMs);
m_model.setLayer(m_layerBeforeSwitch);
}
@@ -158,7 +187,12 @@ void KeyboardController::setContext(const TextContext &context)
void KeyboardController::setOptions(const TypingOptions &options)
{
if (m_options.holdKeys && !options.holdKeys)
m_hardware.releaseAll();
m_options = options;
m_model.setShiftLevel(shiftLevel());
Q_EMIT shiftChanged();
Q_EMIT modifiersChanged();
autoCapitalize();
}
@@ -254,6 +288,8 @@ void KeyboardController::autoCapitalize()
ShiftLevel KeyboardController::shiftLevel() const
{
if (m_options.holdKeys)
return m_hardware.shiftLevel();
if (m_shift.appliesToShortcuts())
return ShiftLevel::Shifted;
return m_shift.isActive() ? ShiftLevel::Capitals : ShiftLevel::None;
+7 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "DeadKeys.h"
#include "HardwareKeys.h"
#include "KeyRepeater.h"
#include "Modifiers.h"
#include "ShiftState.h"
@@ -43,7 +44,9 @@ public:
KeyboardController(InputEngine &engine, LayoutModel &model, QObject *parent = nullptr);
ShiftMode shiftMode() const;
QVariantMap modifierModes() const { return m_modifiers.modes(); }
QVariantMap modifierModes() const;
// Hold-keys mode: no long-press, flick or trackpad gestures, no rollover.
bool holdsKeys() const { return m_options.holdKeys; }
void keyDown(const KeyDef &key, qint64 timeMs);
void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs);
@@ -73,6 +76,8 @@ private:
void sendKey(quint32 keysym);
Qt::KeyboardModifiers shortcutModifiers() const;
ShiftLevel shiftLevel() const;
// Hold-keys mode: forwards the key to m_hardware; false if it isn't one it presses.
bool holdKey(const KeyDef &key, bool pressed);
void useModifiers();
void deleteBackward(bool wholeWord);
void sendRepeatedKeysym();
@@ -86,6 +91,7 @@ private:
TypingOptions m_options;
ShiftState m_shift;
Modifiers m_modifiers;
HardwareKeys m_hardware;
DeadKeys m_deadKeys;
KeyRepeater m_backspaceRepeater;
KeyRepeater m_keysymRepeater; // arrows, Delete, ...
+4 -1
View File
@@ -99,7 +99,9 @@ void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs)
{
if (!m_model || !m_controller)
return;
commitPendingTaps(timeMs);
// Rollover would let go of keys still held, which hold-keys mode must not.
if (!m_controller->holdsKeys())
commitPendingTaps(timeMs);
const int index = m_model->keyAt(pos);
if (index < 0)
@@ -107,6 +109,7 @@ void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs)
PointerTracker::Config config;
config.flickDistance = m_model->keyRect(index).height() * FlickDistanceRatio;
config.gestures = !m_controller->holdsKeys();
const Pointer pointer{PointerTracker(m_model->key(index), pos, config), index, -1, m_nextSerial++};
m_pointers.insert(id, pointer);
m_model->setPressed(index, true);
+3 -3
View File
@@ -12,7 +12,7 @@ PointerTracker::PointerTracker(const KeyDef &key, QPointF start, Config config)
bool PointerTracker::wantsLongPress() const
{
return m_key.type == KeyType::Space || !m_key.alternates.isEmpty();
return m_config.gestures && (m_key.type == KeyType::Space || !m_key.alternates.isEmpty());
}
void PointerTracker::moveTo(QPointF pos)
@@ -28,13 +28,13 @@ void PointerTracker::moveTo(QPointF pos)
if (m_key.type == KeyType::LayerSwitch)
m_gesture = Gesture::Slide;
else if (!m_key.flick.isEmpty() && delta.y() > qAbs(delta.x()))
else if (m_config.gestures && !m_key.flick.isEmpty() && delta.y() > qAbs(delta.x()))
m_gesture = Gesture::Flick;
}
bool PointerTracker::longPressElapsed()
{
if (m_gesture != Gesture::Tap || m_moved)
if (!m_config.gestures || m_gesture != Gesture::Tap || m_moved)
return false;
if (m_key.type == KeyType::Space) {
+1
View File
@@ -24,6 +24,7 @@ public:
int longPressMs = 450;
qreal trackpadStep = 14; // horizontal travel per cursor step
qreal trackpadRowStep = 40; // vertical travel per line
bool gestures = true; // false: only taps and layer slides (hold-keys mode)
};
PointerTracker(const KeyDef &key, QPointF start, Config config);
+1
View File
@@ -5,6 +5,7 @@
struct TypingOptions {
bool autoCapitalize = true;
bool doubleSpacePeriod = true;
bool holdKeys = false; // keys stay down while touched, like a hardware keyboard (see HardwareKeys)
bool operator==(const TypingOptions &) const = default;
};
+10
View File
@@ -7,6 +7,7 @@
namespace {
constexpr auto AutoCapitalizeKey = "typing/autoCapitalize";
constexpr auto DoubleSpacePeriodKey = "typing/doubleSpacePeriod";
constexpr auto HoldKeysKey = "typing/holdKeys";
constexpr auto VisibleSectionsKey = "layout/visibleSections";
constexpr auto FullKeyboardKey = "layout/fullKeyboard";
constexpr auto AlwaysVisibleKey = "window/alwaysVisible";
@@ -19,6 +20,7 @@ Settings::Settings(QObject *parent)
const TypingOptions defaults;
m_typing.autoCapitalize = store.value(AutoCapitalizeKey, defaults.autoCapitalize).toBool();
m_typing.doubleSpacePeriod = store.value(DoubleSpacePeriodKey, defaults.doubleSpacePeriod).toBool();
m_typing.holdKeys = store.value(HoldKeysKey, defaults.holdKeys).toBool();
m_visibleSections = store.value(VisibleSectionsKey, QStringList{MainSectionId}).toStringList();
if (m_visibleSections.isEmpty())
m_visibleSections = {MainSectionId};
@@ -72,6 +74,13 @@ void Settings::setDoubleSpacePeriod(bool enabled)
setTypingOptions(options);
}
void Settings::setHoldKeys(bool enabled)
{
TypingOptions options = m_typing;
options.holdKeys = enabled;
setTypingOptions(options);
}
void Settings::setTypingOptions(const TypingOptions &options)
{
if (options == m_typing)
@@ -80,5 +89,6 @@ void Settings::setTypingOptions(const TypingOptions &options)
QSettings store;
store.setValue(AutoCapitalizeKey, m_typing.autoCapitalize);
store.setValue(DoubleSpacePeriodKey, m_typing.doubleSpacePeriod);
store.setValue(HoldKeysKey, m_typing.holdKeys);
Q_EMIT typingOptionsChanged();
}
+3
View File
@@ -15,6 +15,7 @@ class Settings : public QObject
QML_UNCREATABLE("Created in C++")
Q_PROPERTY(bool autoCapitalize READ autoCapitalize WRITE setAutoCapitalize NOTIFY typingOptionsChanged)
Q_PROPERTY(bool doubleSpacePeriod READ doubleSpacePeriod WRITE setDoubleSpacePeriod NOTIFY typingOptionsChanged)
Q_PROPERTY(bool holdKeys READ holdKeys WRITE setHoldKeys NOTIFY typingOptionsChanged)
// Which key blocks are shown: "main" (the typing keys) and template section ids.
Q_PROPERTY(QStringList visibleSections READ visibleSections NOTIFY visibleSectionsChanged)
// iPad Pro-style full keyboard (number row, tab, ctrl/alt/super) instead of the compact one.
@@ -31,6 +32,8 @@ public:
void setAutoCapitalize(bool enabled);
bool doubleSpacePeriod() const { return m_typing.doubleSpacePeriod; }
void setDoubleSpacePeriod(bool enabled);
bool holdKeys() const { return m_typing.holdKeys; }
void setHoldKeys(bool enabled);
QStringList visibleSections() const { return m_visibleSections; }
// Hiding the last visible section is refused: something must stay on screen.