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
+3
View File
@@ -5,6 +5,8 @@ qt_add_library(sloposk_core STATIC
engine/InputEngine.h
engine/DirectEngine.h engine/DirectEngine.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/KeyboardSurface.h input/KeyboardSurface.cpp
layout/Layout.h
@@ -19,6 +21,7 @@ set(SLOPOSK_QML_FILES
Main.qml
Keyboard.qml
Key.qml
AlternatesPopup.qml
DragHandle.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);
break;
case KeyType::LayerSwitch:
m_layerBeforeSwitch = m_model.layerName();
m_model.setLayer(key.target);
break;
default:
@@ -81,6 +82,24 @@ void KeyboardController::keyCancelled(const KeyDef &key)
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()
{
const auto &layouts = m_layouts.layouts();
+6
View File
@@ -4,6 +4,7 @@
#include "layout/Layout.h"
#include <QObject>
#include <QPoint>
#include <QtQml/qqmlregistration.h>
class InputEngine;
@@ -35,6 +36,10 @@ public:
void keyDown(const KeyDef &key, qint64 timeMs);
void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs);
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();
@@ -52,4 +57,5 @@ private:
const LayoutRepository &m_layouts;
ShiftState m_shift;
int m_layoutIndex = 0;
QString m_layerBeforeSwitch;
};
+150 -20
View File
@@ -1,12 +1,22 @@
#include "KeyboardSurface.h"
#include "KeyboardController.h"
#include "layout/LayoutModel.h"
#include <QTimer>
namespace {
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)
: QQuickItem(parent)
{
@@ -38,6 +48,8 @@ void KeyboardSurface::setController(KeyboardController *controller)
Q_EMIT controllerChanged();
}
// --- Event plumbing
void KeyboardSurface::touchEvent(QTouchEvent *event)
{
if (event->type() == QEvent::TouchCancel) {
@@ -56,10 +68,8 @@ void KeyboardSurface::touchEvent(QTouchEvent *event)
event->accept();
}
void KeyboardSurface::touchUngrabEvent()
{
cancelAll();
}
void KeyboardSurface::touchUngrabEvent() { cancelAll(); }
void KeyboardSurface::mouseUngrabEvent() { cancelAll(); }
void KeyboardSurface::mousePressEvent(QMouseEvent *event)
{
@@ -76,11 +86,6 @@ void KeyboardSurface::mouseReleaseEvent(QMouseEvent *event)
pointerUp(MousePointerId, qint64(event->timestamp()));
}
void KeyboardSurface::mouseUngrabEvent()
{
cancelAll();
}
void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry)
{
QQuickItem::geometryChange(newGeometry, oldGeometry);
@@ -88,25 +93,55 @@ void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &ol
m_model->setSize(newGeometry.size());
}
// --- Pointer lifecycle
void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs)
{
if (!m_model || !m_controller)
return;
commitPendingTaps(timeMs);
const int index = m_model->keyAt(pos);
if (index < 0)
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_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)
{
auto it = m_pointers.find(id);
if (it != m_pointers.end())
it->position = pos;
const auto it = m_pointers.find(id);
if (it == m_pointers.end())
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)
@@ -116,9 +151,100 @@ void KeyboardSurface::pointerUp(int id, qint64 timeMs)
return;
const Pointer pointer = *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_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()
@@ -126,14 +252,18 @@ void KeyboardSurface::cancelAll()
const auto pointers = std::exchange(m_pointers, {});
for (const Pointer &pointer : pointers) {
if (m_model)
m_model->setPressed(pointer.index, false);
release(pointer);
if (m_controller)
m_controller->keyCancelled(pointer.key);
m_controller->keyCancelled(pointer.tracker.key());
}
m_alternates.close();
setTrackpadActive(false);
}
void KeyboardSurface::forgetIndices()
{
for (Pointer &pointer : m_pointers)
for (Pointer &pointer : m_pointers) {
pointer.index = -1;
pointer.hoverIndex = -1;
}
}
+26 -8
View File
@@ -1,22 +1,26 @@
#pragma once
#include "AlternatesPicker.h"
#include "KeyboardController.h"
#include "layout/Layout.h"
#include "PointerTracker.h"
#include "layout/LayoutModel.h"
#include <QHash>
#include <QPointer>
#include <QQuickItem>
// Receives all touch points (and the mouse) over the keyboard, maps them to
// keys and forwards key presses to the controller. Tracks each pointer
// separately so several fingers can type at once.
// Receives all touch points (and the mouse) over the keyboard, runs one
// PointerTracker per finger, and turns finished gestures into controller
// calls. Several fingers can type at once: a new press commits pending taps
// (rollover), as on iOS.
class KeyboardSurface : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(LayoutModel *model READ model WRITE setModel NOTIFY modelChanged)
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:
explicit KeyboardSurface(QQuickItem *parent = nullptr);
@@ -25,10 +29,13 @@ public:
void setModel(LayoutModel *model);
KeyboardController *controller() const { return m_controller; }
void setController(KeyboardController *controller);
AlternatesPicker *alternates() { return &m_alternates; }
bool isTrackpadActive() const { return m_trackpadActive; }
Q_SIGNALS:
void modelChanged();
void controllerChanged();
void trackpadActiveChanged();
protected:
void touchEvent(QTouchEvent *event) override;
@@ -41,19 +48,30 @@ protected:
private:
struct Pointer {
KeyDef key; // copied: the model may switch layers mid-press
int index; // into the model, -1 once the layer changed
QPointF start;
QPointF position;
PointerTracker tracker; // holds a copy of the key: layers may change mid-press
int index = -1; // pressed key in the model, -1 once the layer changed
int hoverIndex = -1; // key under the finger while sliding
quint64 serial = 0; // tells a reused touch id from the original
};
void pointerDown(int id, QPointF pos, qint64 timeMs);
void pointerMove(int id, QPointF pos);
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 forgetIndices();
QPointer<LayoutModel> m_model;
QPointer<KeyboardController> m_controller;
AlternatesPicker m_alternates;
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 KeyHeightRole: return rect.height();
case PressedRole: return m_pressCount.at(index.row()) > 0;
case FlickProgressRole: return m_flickProgress.at(index.row());
}
return {};
}
@@ -65,6 +66,7 @@ QHash<int, QByteArray> LayoutModel::roleNames() const
{KeyWidthRole, "keyWidth"},
{KeyHeightRole, "keyHeight"},
{PressedRole, "pressed"},
{FlickProgressRole, "flickProgress"},
};
}
@@ -115,6 +117,14 @@ void LayoutModel::setPressed(int row, bool pressed)
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 nearest = -1;
@@ -142,6 +152,7 @@ void LayoutModel::rebuild()
}
}
m_pressCount = QList<int>(m_keys.size(), 0);
m_flickProgress = QList<qreal>(m_keys.size(), 0.0);
relayout();
endResetModel();
}
+5
View File
@@ -26,6 +26,7 @@ public:
KeyWidthRole,
KeyHeightRole,
PressedRole,
FlickProgressRole,
};
using QAbstractListModel::QAbstractListModel;
@@ -42,10 +43,13 @@ public:
void setSize(QSizeF size);
void setUppercase(bool uppercase);
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.
int keyAt(QPointF pos) const;
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:
void layoutChanged();
@@ -63,4 +67,5 @@ private:
QList<KeyDef> m_keys;
QList<QRectF> m_rects;
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)));
}
void FloatingWindow::setInputRegion(const QRectF &region)
{
m_window->setMask(QRegion(region.toAlignedRect()));
}
void FloatingWindow::show()
{
m_window->setVisible(true);
+3
View File
@@ -2,6 +2,7 @@
#include <QObject>
#include <QPoint>
#include <QRectF>
class QWindow;
namespace LayerShellQt { class Window; }
@@ -22,6 +23,8 @@ public:
void setPosition(QPoint position);
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 hide();