Gestures: flick, long-press alternates, trackpad, layer slide, rollover

- PointerTracker: per-finger gesture state machine (pure, unit-tested)
- AlternatesPicker + AlternatesPopup.qml: long-press choices, finger selects
- Space long-press trackpad moves the cursor with arrow keysyms
- Slide from .?123 onto a symbol types it and switches back
- New finger commits pending taps (iOS rollover)
- Flick animation: secondary symbol slides down as the finger moves
- Transparent popup margin with an input region that passes clicks through
- tst_keyboardsurface: end-to-end gesture tests on the real QML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:55:49 +02:00
co-authored by Claude Opus 5.5
parent be14bbcc62
commit 2b5edeb365
23 changed files with 901 additions and 35 deletions
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
import Sloposk
// Long-press choices above a key; the one under the finger is highlighted.
Item {
id: popup
required property AlternatesPicker picker
visible: picker.open
x: picker.rect.x
y: picker.rect.y
width: picker.rect.width
height: picker.rect.height
Rectangle {
anchors { fill: background; topMargin: 1; bottomMargin: -2 }
radius: background.radius
color: Theme.keyShadow
}
Rectangle {
id: background
anchors.fill: parent
radius: Theme.keyRadius + 2
color: Theme.characterKey
}
Row {
anchors.fill: parent
Repeater {
model: popup.picker.choices
delegate: Rectangle {
required property string modelData
required property int index
readonly property bool selected: index === popup.picker.selected
width: popup.width / popup.picker.choices.length
height: popup.height
radius: Theme.keyRadius
color: selected ? Theme.accent : "transparent"
Text {
anchors.centerIn: parent
text: parent.modelData
font.pixelSize: parent.height * 0.4
color: parent.selected ? Theme.accentText : Theme.text
}
}
}
}
}
+13 -4
View File
@@ -13,7 +13,9 @@ Item {
required property real keyWidth required property real keyWidth
required property real keyHeight required property real keyHeight
required property bool pressed required property bool pressed
required property real flickProgress
property int shiftMode: KeyboardController.Off property int shiftMode: KeyboardController.Off
property bool blank: false // labels hidden, e.g. in trackpad mode
readonly property bool isCharacter: keyType === "char" || keyType === "space" readonly property bool isCharacter: keyType === "char" || keyType === "space"
readonly property bool shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off readonly property bool shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off
@@ -52,13 +54,18 @@ Item {
color: key.lit ? Theme.characterKey : Theme.functionKey color: key.lit ? Theme.characterKey : Theme.functionKey
} }
// Flicking slides the secondary symbol down into the main position.
Text { Text {
id: flick id: flick
visible: text !== "" readonly property real restY: key.height * 0.1
readonly property real centerY: (key.height - height) / 2
visible: text !== "" && !key.blank
text: key.flickLabel text: key.flickLabel
anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: key.height * 0.1 } anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: key.height * 0.24 y: restY + (centerY - restY) * key.flickProgress
color: Theme.secondaryText font.pixelSize: key.height * (0.24 + 0.16 * key.flickProgress)
color: key.flickProgress >= 1 ? Theme.text : Theme.secondaryText
} }
Text { Text {
@@ -70,5 +77,7 @@ Item {
} }
font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4) font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4)
color: Theme.text color: Theme.text
visible: !key.blank
opacity: 1 - key.flickProgress
} }
} }
+6
View File
@@ -8,6 +8,12 @@ KeyboardSurface {
model: surface.model model: surface.model
delegate: Key { delegate: Key {
shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off
blank: surface.trackpadActive
} }
} }
AlternatesPopup {
picker: surface.alternates
z: 1
}
} }
+14 -2
View File
@@ -8,17 +8,29 @@ Window {
required property KeyboardController controller required property KeyboardController controller
property var placement: null // FloatingWindow, set from C++ property var placement: null // FloatingWindow, set from C++
// Transparent room above the keyboard for long-press popups on the top row.
readonly property real popupMargin: 90
// Outside this, input passes through to whatever is below the window.
readonly property rect inputRegion: keyboard.alternates.open
? Qt.rect(0, 0, width, height)
: Qt.rect(panel.x, panel.y, panel.width, panel.height)
width: 1000 width: 1000
height: 340 height: 340 + popupMargin
visible: false visible: false
color: "transparent" color: "transparent"
onInputRegionChanged: placement?.setInputRegion(inputRegion)
onPlacementChanged: placement?.setInputRegion(inputRegion)
Rectangle { Rectangle {
anchors.fill: parent id: panel
anchors { fill: parent; topMargin: root.popupMargin }
radius: Theme.windowRadius radius: Theme.windowRadius
color: Theme.background color: Theme.background
Keyboard { Keyboard {
id: keyboard
anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top } anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top }
anchors.margins: 2 anchors.margins: 2
model: root.layoutModel model: root.layoutModel
+2
View File
@@ -13,6 +13,8 @@ QtObject {
readonly property color text: dark ? "#ffffff" : "#000000" readonly property color text: dark ? "#ffffff" : "#000000"
readonly property color secondaryText: dark ? "#a5a5aa" : "#8a8a8e" readonly property color secondaryText: dark ? "#a5a5aa" : "#8a8a8e"
readonly property color handle: dark ? "#8e8e93" : "#7c7c80" readonly property color handle: dark ? "#8e8e93" : "#7c7c80"
readonly property color accent: dark ? "#0a84ff" : "#007aff"
readonly property color accentText: "#ffffff"
readonly property real windowRadius: 14 readonly property real windowRadius: 14
readonly property real keyRadius: 6 readonly property real keyRadius: 6
+3
View File
@@ -5,6 +5,8 @@ qt_add_library(sloposk_core STATIC
engine/InputEngine.h engine/InputEngine.h
engine/DirectEngine.h engine/DirectEngine.cpp engine/DirectEngine.h engine/DirectEngine.cpp
input/ShiftState.h input/ShiftState.cpp input/ShiftState.h input/ShiftState.cpp
input/PointerTracker.h input/PointerTracker.cpp
input/AlternatesPicker.h input/AlternatesPicker.cpp
input/KeyboardController.h input/KeyboardController.cpp input/KeyboardController.h input/KeyboardController.cpp
input/KeyboardSurface.h input/KeyboardSurface.cpp input/KeyboardSurface.h input/KeyboardSurface.cpp
layout/Layout.h layout/Layout.h
@@ -19,6 +21,7 @@ set(SLOPOSK_QML_FILES
Main.qml Main.qml
Keyboard.qml Keyboard.qml
Key.qml Key.qml
AlternatesPopup.qml
DragHandle.qml DragHandle.qml
Theme.qml Theme.qml
) )
+37
View File
@@ -0,0 +1,37 @@
#include "AlternatesPicker.h"
#include <QtMath>
void AlternatesPicker::open(const QRectF &keyRect, const QStringList &choices, qreal boundsWidth, qreal gap)
{
if (choices.isEmpty())
return;
const qreal width = keyRect.width() * choices.size();
const qreal left = qBound(0.0, keyRect.left(), qMax(0.0, boundsWidth - width));
m_rect = QRectF(left, keyRect.top() - keyRect.height() - gap, width, keyRect.height());
m_choices = choices;
m_selected = -1;
selectAt(keyRect.center().x()); // emits changed()
}
void AlternatesPicker::selectAt(qreal x)
{
if (!isOpen())
return;
const qreal cellWidth = m_rect.width() / m_choices.size();
const int index = qBound(0, int(qFloor((x - m_rect.left()) / cellWidth)), int(m_choices.size()) - 1);
if (index == m_selected)
return;
m_selected = index;
Q_EMIT changed();
}
void AlternatesPicker::close()
{
if (!isOpen())
return;
m_choices.clear();
m_selected = -1;
Q_EMIT changed();
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <QObject>
#include <QRectF>
#include <QStringList>
#include <QtQml/qqmlregistration.h>
// State of the long-press popup: where it sits and which choice is under the
// finger. Drawn by AlternatesPopup.qml.
class AlternatesPicker : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Owned by KeyboardSurface")
Q_PROPERTY(bool open READ isOpen NOTIFY changed)
Q_PROPERTY(QStringList choices READ choices NOTIFY changed)
Q_PROPERTY(QRectF rect READ rect NOTIFY changed)
Q_PROPERTY(int selected READ selected NOTIFY changed)
public:
using QObject::QObject;
bool isOpen() const { return !m_choices.isEmpty(); }
QStringList choices() const { return m_choices; }
QRectF rect() const { return m_rect; }
int selected() const { return m_selected; }
QString selectedText() const { return m_choices.value(m_selected); }
// Places one key-sized cell per choice just above the key, starting over it
// and extending right (shifted left near the edge to stay within
// [0, boundsWidth]). The cell above the finger starts selected.
void open(const QRectF &keyRect, const QStringList &choices, qreal boundsWidth, qreal gap);
void selectAt(qreal x);
void close();
Q_SIGNALS:
void changed();
private:
QStringList m_choices;
QRectF m_rect;
int m_selected = -1;
};
+19
View File
@@ -37,6 +37,7 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs)
m_engine.sendKeysym(XKB_KEY_BackSpace); m_engine.sendKeysym(XKB_KEY_BackSpace);
break; break;
case KeyType::LayerSwitch: case KeyType::LayerSwitch:
m_layerBeforeSwitch = m_model.layerName();
m_model.setLayer(key.target); m_model.setLayer(key.target);
break; break;
default: default:
@@ -81,6 +82,24 @@ void KeyboardController::keyCancelled(const KeyDef &key)
changeShift([&] { m_shift.release(); }); changeShift([&] { m_shift.release(); });
} }
void KeyboardController::slideReleased(const KeyDef &target, qint64 timeMs)
{
keyUp(target, KeyOutput::Primary, {}, timeMs);
m_model.setLayer(m_layerBeforeSwitch);
}
void KeyboardController::moveCursor(QPoint steps)
{
const auto repeat = [this](int count, quint32 keysym) {
for (int i = 0; i < count; ++i)
m_engine.sendKeysym(keysym);
};
repeat(qMax(0, steps.x()), XKB_KEY_Right);
repeat(qMax(0, -steps.x()), XKB_KEY_Left);
repeat(qMax(0, steps.y()), XKB_KEY_Down);
repeat(qMax(0, -steps.y()), XKB_KEY_Up);
}
void KeyboardController::nextLayout() void KeyboardController::nextLayout()
{ {
const auto &layouts = m_layouts.layouts(); const auto &layouts = m_layouts.layouts();
+6
View File
@@ -4,6 +4,7 @@
#include "layout/Layout.h" #include "layout/Layout.h"
#include <QObject> #include <QObject>
#include <QPoint>
#include <QtQml/qqmlregistration.h> #include <QtQml/qqmlregistration.h>
class InputEngine; class InputEngine;
@@ -35,6 +36,10 @@ public:
void keyDown(const KeyDef &key, qint64 timeMs); void keyDown(const KeyDef &key, qint64 timeMs);
void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs); void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs);
void keyCancelled(const KeyDef &key); void keyCancelled(const KeyDef &key);
// Finger slid from a layer key onto `target`: type it, then switch back.
void slideReleased(const KeyDef &target, qint64 timeMs);
// Space-bar trackpad: move the text cursor by whole steps.
void moveCursor(QPoint steps);
Q_INVOKABLE void nextLayout(); Q_INVOKABLE void nextLayout();
@@ -52,4 +57,5 @@ private:
const LayoutRepository &m_layouts; const LayoutRepository &m_layouts;
ShiftState m_shift; ShiftState m_shift;
int m_layoutIndex = 0; int m_layoutIndex = 0;
QString m_layerBeforeSwitch;
}; };
+150 -20
View File
@@ -1,12 +1,22 @@
#include "KeyboardSurface.h" #include "KeyboardSurface.h"
#include "KeyboardController.h" #include <QTimer>
#include "layout/LayoutModel.h"
namespace { namespace {
constexpr int MousePointerId = -1; constexpr int MousePointerId = -1;
constexpr qreal FlickDistanceRatio = 0.4; // of the key height
constexpr qreal PopupGap = 8;
using Gesture = PointerTracker::Gesture;
bool isTypingKey(const KeyDef &key)
{
return key.type == KeyType::Character || key.type == KeyType::Space;
} }
} // namespace
KeyboardSurface::KeyboardSurface(QQuickItem *parent) KeyboardSurface::KeyboardSurface(QQuickItem *parent)
: QQuickItem(parent) : QQuickItem(parent)
{ {
@@ -38,6 +48,8 @@ void KeyboardSurface::setController(KeyboardController *controller)
Q_EMIT controllerChanged(); Q_EMIT controllerChanged();
} }
// --- Event plumbing
void KeyboardSurface::touchEvent(QTouchEvent *event) void KeyboardSurface::touchEvent(QTouchEvent *event)
{ {
if (event->type() == QEvent::TouchCancel) { if (event->type() == QEvent::TouchCancel) {
@@ -56,10 +68,8 @@ void KeyboardSurface::touchEvent(QTouchEvent *event)
event->accept(); event->accept();
} }
void KeyboardSurface::touchUngrabEvent() void KeyboardSurface::touchUngrabEvent() { cancelAll(); }
{ void KeyboardSurface::mouseUngrabEvent() { cancelAll(); }
cancelAll();
}
void KeyboardSurface::mousePressEvent(QMouseEvent *event) void KeyboardSurface::mousePressEvent(QMouseEvent *event)
{ {
@@ -76,11 +86,6 @@ void KeyboardSurface::mouseReleaseEvent(QMouseEvent *event)
pointerUp(MousePointerId, qint64(event->timestamp())); pointerUp(MousePointerId, qint64(event->timestamp()));
} }
void KeyboardSurface::mouseUngrabEvent()
{
cancelAll();
}
void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry)
{ {
QQuickItem::geometryChange(newGeometry, oldGeometry); QQuickItem::geometryChange(newGeometry, oldGeometry);
@@ -88,25 +93,55 @@ void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &ol
m_model->setSize(newGeometry.size()); m_model->setSize(newGeometry.size());
} }
// --- Pointer lifecycle
void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs) void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs)
{ {
if (!m_model || !m_controller) if (!m_model || !m_controller)
return; return;
commitPendingTaps(timeMs);
const int index = m_model->keyAt(pos); const int index = m_model->keyAt(pos);
if (index < 0) if (index < 0)
return; return;
const Pointer pointer{m_model->key(index), index, pos, pos}; PointerTracker::Config config;
config.flickDistance = m_model->keyRect(index).height() * FlickDistanceRatio;
const Pointer pointer{PointerTracker(m_model->key(index), pos, config), index, -1, m_nextSerial++};
m_pointers.insert(id, pointer); m_pointers.insert(id, pointer);
m_model->setPressed(index, true); m_model->setPressed(index, true);
m_controller->keyDown(pointer.key, timeMs); m_controller->keyDown(pointer.tracker.key(), timeMs);
if (pointer.tracker.wantsLongPress()) {
QTimer::singleShot(config.longPressMs, this,
[this, id, serial = pointer.serial] { longPressElapsed(id, serial); });
}
} }
void KeyboardSurface::pointerMove(int id, QPointF pos) void KeyboardSurface::pointerMove(int id, QPointF pos)
{ {
auto it = m_pointers.find(id); const auto it = m_pointers.find(id);
if (it != m_pointers.end()) if (it == m_pointers.end())
it->position = pos; return;
Pointer &pointer = *it;
pointer.tracker.moveTo(pos);
switch (pointer.tracker.gesture()) {
case Gesture::Flick:
m_model->setFlickProgress(pointer.index, pointer.tracker.flickProgress());
break;
case Gesture::LongPress:
m_alternates.selectAt(pos.x());
break;
case Gesture::Trackpad:
m_controller->moveCursor(pointer.tracker.takeTrackpadSteps());
break;
case Gesture::Slide:
updateSlideHover(pointer);
break;
case Gesture::Tap:
break;
}
} }
void KeyboardSurface::pointerUp(int id, qint64 timeMs) void KeyboardSurface::pointerUp(int id, qint64 timeMs)
@@ -116,9 +151,100 @@ void KeyboardSurface::pointerUp(int id, qint64 timeMs)
return; return;
const Pointer pointer = *it; const Pointer pointer = *it;
m_pointers.erase(it); m_pointers.erase(it);
finish(pointer, timeMs);
}
void KeyboardSurface::longPressElapsed(int id, quint64 serial)
{
const auto it = m_pointers.find(id);
if (it == m_pointers.end() || it->serial != serial || !it->tracker.longPressElapsed())
return;
if (it->tracker.gesture() == Gesture::Trackpad)
setTrackpadActive(true);
else if (it->tracker.gesture() == Gesture::LongPress)
openAlternates(*it);
}
// --- Gesture outcomes
void KeyboardSurface::finish(const Pointer &pointer, qint64 timeMs)
{
release(pointer);
const PointerTracker &tracker = pointer.tracker;
const KeyDef &key = tracker.key();
switch (tracker.gesture()) {
case Gesture::Tap:
m_controller->keyUp(key, KeyOutput::Primary, {}, timeMs);
break;
case Gesture::Flick:
m_controller->keyUp(key, tracker.flickCommitted() ? KeyOutput::Flick : KeyOutput::Primary, {}, timeMs);
break;
case Gesture::LongPress:
m_controller->keyUp(key, KeyOutput::Alternate, m_alternates.selectedText(), timeMs);
m_alternates.close();
break;
case Gesture::Trackpad:
m_controller->keyCancelled(key);
setTrackpadActive(false);
break;
case Gesture::Slide:
if (pointer.hoverIndex >= 0 && isTypingKey(m_model->key(pointer.hoverIndex)))
m_controller->slideReleased(m_model->key(pointer.hoverIndex), timeMs);
else
m_controller->keyUp(key, KeyOutput::Primary, {}, timeMs);
break;
}
}
// Clears the visual state a pointer left on the model.
void KeyboardSurface::release(const Pointer &pointer)
{
m_model->setPressed(pointer.index, false); m_model->setPressed(pointer.index, false);
m_controller->keyUp(pointer.key, KeyOutput::Primary, {}, timeMs); m_model->setPressed(pointer.hoverIndex, false);
m_model->setFlickProgress(pointer.index, 0);
}
void KeyboardSurface::commitPendingTaps(qint64 timeMs)
{
for (auto it = m_pointers.begin(); it != m_pointers.end();) {
if (it->tracker.gesture() == Gesture::Tap && isTypingKey(it->tracker.key())) {
const Pointer pointer = *it;
it = m_pointers.erase(it);
finish(pointer, timeMs);
} else {
++it;
}
}
}
void KeyboardSurface::updateSlideHover(Pointer &pointer)
{
const int hover = m_model->keyAt(pointer.tracker.position());
if (hover == pointer.hoverIndex)
return;
m_model->setPressed(pointer.hoverIndex, false);
m_model->setPressed(hover, true);
pointer.hoverIndex = hover;
}
void KeyboardSurface::openAlternates(const Pointer &pointer)
{
QStringList choices = pointer.tracker.key().alternates;
if (m_model->isUppercase()) {
for (QString &choice : choices)
choice = choice.toUpper();
}
m_alternates.open(m_model->keyRect(pointer.index), choices, width(), PopupGap);
}
void KeyboardSurface::setTrackpadActive(bool active)
{
if (active == m_trackpadActive)
return;
m_trackpadActive = active;
Q_EMIT trackpadActiveChanged();
} }
void KeyboardSurface::cancelAll() void KeyboardSurface::cancelAll()
@@ -126,14 +252,18 @@ void KeyboardSurface::cancelAll()
const auto pointers = std::exchange(m_pointers, {}); const auto pointers = std::exchange(m_pointers, {});
for (const Pointer &pointer : pointers) { for (const Pointer &pointer : pointers) {
if (m_model) if (m_model)
m_model->setPressed(pointer.index, false); release(pointer);
if (m_controller) if (m_controller)
m_controller->keyCancelled(pointer.key); m_controller->keyCancelled(pointer.tracker.key());
} }
m_alternates.close();
setTrackpadActive(false);
} }
void KeyboardSurface::forgetIndices() void KeyboardSurface::forgetIndices()
{ {
for (Pointer &pointer : m_pointers) for (Pointer &pointer : m_pointers) {
pointer.index = -1; pointer.index = -1;
pointer.hoverIndex = -1;
}
} }
+26 -8
View File
@@ -1,22 +1,26 @@
#pragma once #pragma once
#include "AlternatesPicker.h"
#include "KeyboardController.h" #include "KeyboardController.h"
#include "layout/Layout.h" #include "PointerTracker.h"
#include "layout/LayoutModel.h" #include "layout/LayoutModel.h"
#include <QHash> #include <QHash>
#include <QPointer> #include <QPointer>
#include <QQuickItem> #include <QQuickItem>
// Receives all touch points (and the mouse) over the keyboard, maps them to // Receives all touch points (and the mouse) over the keyboard, runs one
// keys and forwards key presses to the controller. Tracks each pointer // PointerTracker per finger, and turns finished gestures into controller
// separately so several fingers can type at once. // calls. Several fingers can type at once: a new press commits pending taps
// (rollover), as on iOS.
class KeyboardSurface : public QQuickItem class KeyboardSurface : public QQuickItem
{ {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
Q_PROPERTY(LayoutModel *model READ model WRITE setModel NOTIFY modelChanged) Q_PROPERTY(LayoutModel *model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(KeyboardController *controller READ controller WRITE setController NOTIFY controllerChanged) Q_PROPERTY(KeyboardController *controller READ controller WRITE setController NOTIFY controllerChanged)
Q_PROPERTY(AlternatesPicker *alternates READ alternates CONSTANT)
Q_PROPERTY(bool trackpadActive READ isTrackpadActive NOTIFY trackpadActiveChanged)
public: public:
explicit KeyboardSurface(QQuickItem *parent = nullptr); explicit KeyboardSurface(QQuickItem *parent = nullptr);
@@ -25,10 +29,13 @@ public:
void setModel(LayoutModel *model); void setModel(LayoutModel *model);
KeyboardController *controller() const { return m_controller; } KeyboardController *controller() const { return m_controller; }
void setController(KeyboardController *controller); void setController(KeyboardController *controller);
AlternatesPicker *alternates() { return &m_alternates; }
bool isTrackpadActive() const { return m_trackpadActive; }
Q_SIGNALS: Q_SIGNALS:
void modelChanged(); void modelChanged();
void controllerChanged(); void controllerChanged();
void trackpadActiveChanged();
protected: protected:
void touchEvent(QTouchEvent *event) override; void touchEvent(QTouchEvent *event) override;
@@ -41,19 +48,30 @@ protected:
private: private:
struct Pointer { struct Pointer {
KeyDef key; // copied: the model may switch layers mid-press PointerTracker tracker; // holds a copy of the key: layers may change mid-press
int index; // into the model, -1 once the layer changed int index = -1; // pressed key in the model, -1 once the layer changed
QPointF start; int hoverIndex = -1; // key under the finger while sliding
QPointF position; quint64 serial = 0; // tells a reused touch id from the original
}; };
void pointerDown(int id, QPointF pos, qint64 timeMs); void pointerDown(int id, QPointF pos, qint64 timeMs);
void pointerMove(int id, QPointF pos); void pointerMove(int id, QPointF pos);
void pointerUp(int id, qint64 timeMs); void pointerUp(int id, qint64 timeMs);
void longPressElapsed(int id, quint64 serial);
void finish(const Pointer &pointer, qint64 timeMs);
void release(const Pointer &pointer);
void commitPendingTaps(qint64 timeMs);
void updateSlideHover(Pointer &pointer);
void openAlternates(const Pointer &pointer);
void setTrackpadActive(bool active);
void cancelAll(); void cancelAll();
void forgetIndices(); void forgetIndices();
QPointer<LayoutModel> m_model; QPointer<LayoutModel> m_model;
QPointer<KeyboardController> m_controller; QPointer<KeyboardController> m_controller;
AlternatesPicker m_alternates;
QHash<int, Pointer> m_pointers; QHash<int, Pointer> m_pointers;
quint64 m_nextSerial = 1;
bool m_trackpadActive = false;
}; };
+70
View File
@@ -0,0 +1,70 @@
#include "PointerTracker.h"
#include <QtMath>
PointerTracker::PointerTracker(const KeyDef &key, QPointF start, Config config)
: m_key(key)
, m_config(config)
, m_start(start)
, m_position(start)
{
}
bool PointerTracker::wantsLongPress() const
{
return m_key.type == KeyType::Space || !m_key.alternates.isEmpty();
}
void PointerTracker::moveTo(QPointF pos)
{
m_position = pos;
if (m_gesture != Gesture::Tap)
return;
const QPointF delta = pos - m_start;
if (qMax(qAbs(delta.x()), qAbs(delta.y())) <= m_config.slop)
return;
m_moved = true;
if (m_key.type == KeyType::LayerSwitch)
m_gesture = Gesture::Slide;
else if (!m_key.flick.isEmpty() && delta.y() > qAbs(delta.x()))
m_gesture = Gesture::Flick;
}
bool PointerTracker::longPressElapsed()
{
if (m_gesture != Gesture::Tap || m_moved)
return false;
if (m_key.type == KeyType::Space) {
m_gesture = Gesture::Trackpad;
m_trackpadOrigin = m_position;
return true;
}
if (!m_key.alternates.isEmpty()) {
m_gesture = Gesture::LongPress;
return true;
}
return false;
}
qreal PointerTracker::flickProgress() const
{
if (m_gesture != Gesture::Flick)
return 0;
return qBound(0.0, (m_position.y() - m_start.y()) / m_config.flickDistance, 1.0);
}
QPoint PointerTracker::takeTrackpadSteps()
{
if (m_gesture != Gesture::Trackpad)
return {};
const QPointF travel = m_position - m_trackpadOrigin;
// Truncate towards zero so jitter around the origin doesn't step.
const QPoint total(int(travel.x() / m_config.trackpadStep), int(travel.y() / m_config.trackpadRowStep));
const QPoint steps = total - m_trackpadStepsTaken;
m_trackpadStepsTaken = total;
return steps;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include "layout/Layout.h"
#include <QPoint>
#include <QPointF>
// Gesture recognition for one finger on one key. Pure logic: the caller feeds
// positions and tells it when the long-press delay has elapsed.
//
// Tap pressed, not (yet) anything else
// Flick moved down on a key with a flick symbol
// LongPress held still on a key with alternates
// Trackpad held still on space; movement moves the text cursor
// Slide moved off a layer key (type a symbol, then switch back)
class PointerTracker
{
public:
enum class Gesture { Tap, Flick, LongPress, Trackpad, Slide };
struct Config {
qreal slop = 10; // movement that still counts as holding still
qreal flickDistance = 28; // downward travel that commits a flick
int longPressMs = 450;
qreal trackpadStep = 14; // horizontal travel per cursor step
qreal trackpadRowStep = 40; // vertical travel per line
};
PointerTracker(const KeyDef &key, QPointF start, Config config);
explicit PointerTracker(const KeyDef &key = {}, QPointF start = {})
: PointerTracker(key, start, Config{}) {}
const KeyDef &key() const { return m_key; }
Gesture gesture() const { return m_gesture; }
QPointF position() const { return m_position; }
const Config &config() const { return m_config; }
bool wantsLongPress() const;
void moveTo(QPointF pos);
// Call when the long-press delay has passed; true if the gesture changed.
bool longPressElapsed();
// 0..1: how far a flick has travelled towards committing.
qreal flickProgress() const;
bool flickCommitted() const { return flickProgress() >= 1.0; }
// Whole cursor steps moved since the last call (Trackpad only).
QPoint takeTrackpadSteps();
private:
KeyDef m_key;
Config m_config;
QPointF m_start;
QPointF m_position;
Gesture m_gesture = Gesture::Tap;
bool m_moved = false;
QPointF m_trackpadOrigin;
QPoint m_trackpadStepsTaken;
};
+11
View File
@@ -50,6 +50,7 @@ QVariant LayoutModel::data(const QModelIndex &index, int role) const
case KeyWidthRole: return rect.width(); case KeyWidthRole: return rect.width();
case KeyHeightRole: return rect.height(); case KeyHeightRole: return rect.height();
case PressedRole: return m_pressCount.at(index.row()) > 0; case PressedRole: return m_pressCount.at(index.row()) > 0;
case FlickProgressRole: return m_flickProgress.at(index.row());
} }
return {}; return {};
} }
@@ -65,6 +66,7 @@ QHash<int, QByteArray> LayoutModel::roleNames() const
{KeyWidthRole, "keyWidth"}, {KeyWidthRole, "keyWidth"},
{KeyHeightRole, "keyHeight"}, {KeyHeightRole, "keyHeight"},
{PressedRole, "pressed"}, {PressedRole, "pressed"},
{FlickProgressRole, "flickProgress"},
}; };
} }
@@ -115,6 +117,14 @@ void LayoutModel::setPressed(int row, bool pressed)
Q_EMIT dataChanged(index(row), index(row), {PressedRole}); Q_EMIT dataChanged(index(row), index(row), {PressedRole});
} }
void LayoutModel::setFlickProgress(int row, qreal progress)
{
if (row < 0 || row >= m_flickProgress.size() || qFuzzyCompare(m_flickProgress[row], progress))
return;
m_flickProgress[row] = progress;
Q_EMIT dataChanged(index(row), index(row), {FlickProgressRole});
}
int LayoutModel::keyAt(QPointF pos) const int LayoutModel::keyAt(QPointF pos) const
{ {
int nearest = -1; int nearest = -1;
@@ -142,6 +152,7 @@ void LayoutModel::rebuild()
} }
} }
m_pressCount = QList<int>(m_keys.size(), 0); m_pressCount = QList<int>(m_keys.size(), 0);
m_flickProgress = QList<qreal>(m_keys.size(), 0.0);
relayout(); relayout();
endResetModel(); endResetModel();
} }
+5
View File
@@ -26,6 +26,7 @@ public:
KeyWidthRole, KeyWidthRole,
KeyHeightRole, KeyHeightRole,
PressedRole, PressedRole,
FlickProgressRole,
}; };
using QAbstractListModel::QAbstractListModel; using QAbstractListModel::QAbstractListModel;
@@ -42,10 +43,13 @@ public:
void setSize(QSizeF size); void setSize(QSizeF size);
void setUppercase(bool uppercase); void setUppercase(bool uppercase);
void setPressed(int index, bool pressed); void setPressed(int index, bool pressed);
void setFlickProgress(int index, qreal progress);
// Key under `pos`, or the nearest one; -1 if there are no keys. // Key under `pos`, or the nearest one; -1 if there are no keys.
int keyAt(QPointF pos) const; int keyAt(QPointF pos) const;
const KeyDef &key(int index) const { return m_keys.at(index); } const KeyDef &key(int index) const { return m_keys.at(index); }
QRectF keyRect(int index) const { return m_rects.value(index); }
bool isUppercase() const { return m_uppercase; }
Q_SIGNALS: Q_SIGNALS:
void layoutChanged(); void layoutChanged();
@@ -63,4 +67,5 @@ private:
QList<KeyDef> m_keys; QList<KeyDef> m_keys;
QList<QRectF> m_rects; QList<QRectF> m_rects;
QList<int> m_pressCount; // several fingers may hold one key QList<int> m_pressCount; // several fingers may hold one key
QList<qreal> m_flickProgress;
}; };
+5
View File
@@ -51,6 +51,11 @@ void FloatingWindow::moveBy(qreal dx, qreal dy)
setPosition(m_position + QPoint(qRound(dx), qRound(dy))); setPosition(m_position + QPoint(qRound(dx), qRound(dy)));
} }
void FloatingWindow::setInputRegion(const QRectF &region)
{
m_window->setMask(QRegion(region.toAlignedRect()));
}
void FloatingWindow::show() void FloatingWindow::show()
{ {
m_window->setVisible(true); m_window->setVisible(true);
+3
View File
@@ -2,6 +2,7 @@
#include <QObject> #include <QObject>
#include <QPoint> #include <QPoint>
#include <QRectF>
class QWindow; class QWindow;
namespace LayerShellQt { class Window; } namespace LayerShellQt { class Window; }
@@ -22,6 +23,8 @@ public:
void setPosition(QPoint position); void setPosition(QPoint position);
Q_INVOKABLE void moveBy(qreal dx, qreal dy); Q_INVOKABLE void moveBy(qreal dx, qreal dy);
// Only this part of the window takes input; the rest passes through.
Q_INVOKABLE void setInputRegion(const QRectF &region);
Q_INVOKABLE void show(); Q_INVOKABLE void show();
Q_INVOKABLE void hide(); Q_INVOKABLE void hide();
+7
View File
@@ -9,3 +9,10 @@ sloposk_add_test(tst_shiftstate)
sloposk_add_test(tst_layoutloader) sloposk_add_test(tst_layoutloader)
sloposk_add_test(tst_keygeometry) sloposk_add_test(tst_keygeometry)
sloposk_add_test(tst_controller) sloposk_add_test(tst_controller)
sloposk_add_test(tst_pointertracker)
sloposk_add_test(tst_alternatespicker)
# Needs the QML module itself, not just the C++ backing library.
sloposk_add_test(tst_keyboardsurface)
target_link_libraries(tst_keyboardsurface PRIVATE sloposk_coreplugin)
set_tests_properties(tst_keyboardsurface PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software")
+52
View File
@@ -0,0 +1,52 @@
#include "input/AlternatesPicker.h"
#include <QTest>
using namespace Qt::StringLiterals;
class TestAlternatesPicker : public QObject
{
Q_OBJECT
private Q_SLOTS:
void opensAboveKeyExtendingRight()
{
AlternatesPicker picker;
picker.open(QRectF(100, 100, 50, 60), {u"é"_s, u"è"_s, u"ê"_s}, 1000, 8);
QVERIFY(picker.isOpen());
QCOMPARE(picker.rect(), QRectF(100, 32, 150, 60));
QCOMPARE(picker.selectedText(), u"é");
}
void staysInsideRightEdgeAndSelectsUnderFinger()
{
AlternatesPicker picker;
picker.open(QRectF(900, 100, 50, 60), {u"a"_s, u"b"_s, u"c"_s}, 1000, 8);
QCOMPARE(picker.rect().left(), 850.0);
QCOMPARE(picker.selectedText(), u"b"); // key center x=925 lies over the second cell
}
void selectionFollowsFingerAndClamps()
{
AlternatesPicker picker;
picker.open(QRectF(100, 100, 50, 60), {u"a"_s, u"b"_s, u"c"_s}, 1000, 8);
picker.selectAt(210);
QCOMPARE(picker.selected(), 2);
picker.selectAt(-50);
QCOMPARE(picker.selected(), 0);
picker.selectAt(5000);
QCOMPARE(picker.selected(), 2);
}
void closeClears()
{
AlternatesPicker picker;
picker.open(QRectF(0, 0, 10, 10), {u"a"_s}, 100, 0);
picker.close();
QVERIFY(!picker.isOpen());
QVERIFY(picker.selectedText().isEmpty());
}
};
QTEST_GUILESS_MAIN(TestAlternatesPicker)
#include "tst_alternatespicker.moc"
+16 -1
View File
@@ -1,6 +1,5 @@
#include "engine/InputEngine.h" #include "engine/InputEngine.h"
#include "input/KeyboardController.h" #include "input/KeyboardController.h"
#include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h" #include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h" #include "layout/LayoutRepository.h"
@@ -106,6 +105,22 @@ private Q_SLOTS:
QCOMPARE(model.layerName(), u"numbers"); QCOMPARE(model.layerName(), u"numbers");
} }
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 globeCyclesLayouts() void globeCyclesLayouts()
{ {
QCOMPARE(model.layoutName(), u"English (US)"); QCOMPARE(model.layoutName(), u"English (US)");
+177
View File
@@ -0,0 +1,177 @@
// 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 "input/KeyboardController.h"
#include "input/KeyboardSurface.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include <QQmlComponent>
#include <QQmlEngine>
#include <QQmlExtensionPlugin>
#include <QQuickItem>
#include <QQuickWindow>
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
Q_IMPORT_QML_PLUGIN(SloposkPlugin)
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;
};
constexpr QPoint KeyboardOffset(0, 100); // room above for popups
constexpr int LongPressWaitMs = 600;
} // namespace
class TestKeyboardSurface : public QObject
{
Q_OBJECT
private:
FakeEngine engine;
LayoutRepository repo;
LayoutModel model;
std::unique_ptr<KeyboardController> controller;
QQmlEngine qml;
QQuickWindow window;
QQuickItem *keyboard = nullptr;
QPointingDevice *touchscreen = QTest::createTouchDevice();
// Window coordinates of the center of the key labelled `label`.
QPoint keyCenter(const QString &label, const QString &type = u"char"_s)
{
for (int i = 0; i < model.rowCount(); ++i) {
const QModelIndex idx = model.index(i);
if (idx.data(LayoutModel::KeyTypeRole) == type
&& (label.isEmpty() || idx.data(LayoutModel::LabelRole) == label))
return model.keyRect(i).center().toPoint() + KeyboardOffset;
}
qFatal("no key %s", qPrintable(label));
}
// Qt Quick delivers touch moves on the next frame; let one pass.
void settle() { QTest::qWait(50); }
void snapshot(const QString &name)
{
settle();
const QString dir = qEnvironmentVariable("SLOPOSK_SNAPSHOT_DIR");
if (!dir.isEmpty())
window.grabWindow().save(dir + u'/' + name + u".png"_s);
}
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);
QQmlComponent component(&qml, "Sloposk", "Keyboard");
keyboard = qobject_cast<QQuickItem *>(component.createWithInitialProperties({
{u"model"_s, QVariant::fromValue(&model)},
{u"controller"_s, QVariant::fromValue(controller.get())},
}));
QVERIFY2(keyboard, qPrintable(component.errorString()));
keyboard->setParentItem(window.contentItem());
keyboard->setPosition(KeyboardOffset);
keyboard->setSize({1000, 300});
window.setColor(QColor(u"#d1d3d9"_s));
window.resize(1000, 400);
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
}
void init()
{
engine = {};
}
void tapTypesLetter()
{
QTest::touchEvent(&window, touchscreen).press(0, keyCenter(u"q"_s));
QTest::touchEvent(&window, touchscreen).release(0, keyCenter(u"q"_s));
QCOMPARE(engine.output, u"q");
}
void flickDownTypesSecondarySymbol()
{
const QPoint w = keyCenter(u"w"_s);
QTest::touchEvent(&window, touchscreen).press(0, w);
QTest::touchEvent(&window, touchscreen).move(0, w + QPoint(0, 15));
snapshot(u"flick-halfway"_s);
const int wIndex = model.keyAt(w - KeyboardOffset);
QVERIFY(model.index(wIndex).data(LayoutModel::FlickProgressRole).toReal() > 0.3);
QTest::touchEvent(&window, touchscreen).move(0, w + QPoint(0, 40));
QTest::touchEvent(&window, touchscreen).release(0, w + QPoint(0, 40));
QCOMPARE(engine.output, u"2");
}
void longPressPicksAlternate()
{
const QPoint e = keyCenter(u"e"_s);
const qreal cell = model.keyRect(0).width();
QTest::touchEvent(&window, touchscreen).press(0, e);
QTest::qWait(LongPressWaitMs);
auto *surface = qobject_cast<KeyboardSurface *>(keyboard);
QVERIFY(surface->alternates()->isOpen());
QTest::touchEvent(&window, touchscreen).move(0, e + QPoint(int(cell), 0));
snapshot(u"alternates"_s);
QTest::touchEvent(&window, touchscreen).release(0, e + QPoint(int(cell), 0));
QCOMPARE(engine.output, u"è");
QVERIFY(!surface->alternates()->isOpen());
}
void secondFingerCommitsFirst()
{
const QPoint a = keyCenter(u"a"_s), s = keyCenter(u"s"_s);
QTest::touchEvent(&window, touchscreen).press(0, a);
QTest::touchEvent(&window, touchscreen).stationary(0).press(1, s);
QCOMPARE(engine.output, u"a");
QTest::touchEvent(&window, touchscreen).release(0, a).stationary(1);
QTest::touchEvent(&window, touchscreen).release(1, s);
QCOMPARE(engine.output, u"as");
}
void slideFromNumbersKeyTypesAndReturns()
{
const QPoint layerKey = keyCenter(u".?123"_s, u"layer"_s);
QTest::touchEvent(&window, touchscreen).press(0, layerKey);
QCOMPARE(model.layerName(), u"numbers");
const QPoint five = keyCenter(u"5"_s);
QTest::touchEvent(&window, touchscreen).move(0, five);
QTest::touchEvent(&window, touchscreen).release(0, five);
QCOMPARE(engine.output, u"5");
QCOMPARE(model.layerName(), u"letters");
}
void spaceTrackpadMovesCursor()
{
const QPoint space = keyCenter({}, u"space"_s);
QTest::touchEvent(&window, touchscreen).press(0, space);
QTest::qWait(LongPressWaitMs);
QTest::touchEvent(&window, touchscreen).move(0, space + QPoint(45, 0));
snapshot(u"trackpad"_s);
QTest::touchEvent(&window, touchscreen).release(0, space + QPoint(45, 0));
QCOMPARE(engine.output, QString());
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Right}));
}
};
QTEST_MAIN(TestKeyboardSurface)
#include "tst_keyboardsurface.moc"
+123
View File
@@ -0,0 +1,123 @@
#include "input/PointerTracker.h"
#include <QTest>
using namespace Qt::StringLiterals;
using Gesture = PointerTracker::Gesture;
namespace {
KeyDef letter(const QString &flick = {}, const QStringList &alternates = {})
{
KeyDef k;
k.label = u"e"_s;
k.flick = flick;
k.alternates = alternates;
return k;
}
KeyDef ofType(KeyType type)
{
KeyDef k;
k.type = type;
return k;
}
PointerTracker::Config config()
{
PointerTracker::Config c;
c.slop = 10;
c.flickDistance = 30;
c.trackpadStep = 10;
c.trackpadRowStep = 40;
return c;
}
} // namespace
class TestPointerTracker : public QObject
{
Q_OBJECT
private Q_SLOTS:
void smallMovementStaysTap()
{
PointerTracker t(letter(u"3"_s), {0, 0}, config());
t.moveTo({5, 8});
QCOMPARE(t.gesture(), Gesture::Tap);
}
void downwardMovementFlicks()
{
PointerTracker t(letter(u"3"_s), {0, 0}, config());
t.moveTo({2, 15});
QCOMPARE(t.gesture(), Gesture::Flick);
QCOMPARE(t.flickProgress(), 0.5);
QVERIFY(!t.flickCommitted());
t.moveTo({2, 45});
QVERIFY(t.flickCommitted());
}
void flickBackUpUncommits()
{
PointerTracker t(letter(u"3"_s), {0, 0}, config());
t.moveTo({0, 40});
t.moveTo({0, 5});
QCOMPARE(t.gesture(), Gesture::Flick);
QVERIFY(!t.flickCommitted());
}
void sidewaysMovementDoesNotFlick()
{
PointerTracker t(letter(u"3"_s), {0, 0}, config());
t.moveTo({30, 15});
QCOMPARE(t.gesture(), Gesture::Tap);
}
void keysWithoutFlickNeverFlick()
{
PointerTracker t(letter(), {0, 0}, config());
t.moveTo({0, 40});
QCOMPARE(t.gesture(), Gesture::Tap);
}
void holdingStillOpensAlternates()
{
PointerTracker t(letter({}, {u"é"_s}), {0, 0}, config());
QVERIFY(t.wantsLongPress());
QVERIFY(t.longPressElapsed());
QCOMPARE(t.gesture(), Gesture::LongPress);
}
void movingCancelsLongPress()
{
PointerTracker t(letter({}, {u"é"_s}), {0, 0}, config());
t.moveTo({20, 0});
QVERIFY(!t.longPressElapsed());
QCOMPARE(t.gesture(), Gesture::Tap);
}
void spaceLongPressBecomesTrackpad()
{
PointerTracker t(ofType(KeyType::Space), {100, 0}, config());
QVERIFY(t.longPressElapsed());
QCOMPARE(t.gesture(), Gesture::Trackpad);
t.moveTo({125, 0});
QCOMPARE(t.takeTrackpadSteps(), QPoint(2, 0));
t.moveTo({131, 0});
QCOMPARE(t.takeTrackpadSteps(), QPoint(1, 0));
t.moveTo({95, -45});
QCOMPARE(t.takeTrackpadSteps(), QPoint(-3, -1));
}
void layerKeySlides()
{
PointerTracker t(ofType(KeyType::LayerSwitch), {0, 0}, config());
t.moveTo({40, -30});
QCOMPARE(t.gesture(), Gesture::Slide);
}
};
QTEST_GUILESS_MAIN(TestPointerTracker)
#include "tst_pointertracker.moc"