From 2b5edeb3657d4dce1019e09e2902831e183567f2 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 14 Oct 2026 14:55:49 +0200 Subject: [PATCH] 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 --- qml/AlternatesPopup.qml | 54 ++++++++++ qml/Key.qml | 17 ++- qml/Keyboard.qml | 6 ++ qml/Main.qml | 16 ++- qml/Theme.qml | 2 + src/CMakeLists.txt | 3 + src/input/AlternatesPicker.cpp | 37 +++++++ src/input/AlternatesPicker.h | 43 ++++++++ src/input/KeyboardController.cpp | 19 ++++ src/input/KeyboardController.h | 6 ++ src/input/KeyboardSurface.cpp | 170 +++++++++++++++++++++++++---- src/input/KeyboardSurface.h | 34 ++++-- src/input/PointerTracker.cpp | 70 ++++++++++++ src/input/PointerTracker.h | 59 +++++++++++ src/layout/LayoutModel.cpp | 11 ++ src/layout/LayoutModel.h | 5 + src/window/FloatingWindow.cpp | 5 + src/window/FloatingWindow.h | 3 + tests/CMakeLists.txt | 7 ++ tests/tst_alternatespicker.cpp | 52 +++++++++ tests/tst_controller.cpp | 17 ++- tests/tst_keyboardsurface.cpp | 177 +++++++++++++++++++++++++++++++ tests/tst_pointertracker.cpp | 123 +++++++++++++++++++++ 23 files changed, 901 insertions(+), 35 deletions(-) create mode 100644 qml/AlternatesPopup.qml create mode 100644 src/input/AlternatesPicker.cpp create mode 100644 src/input/AlternatesPicker.h create mode 100644 src/input/PointerTracker.cpp create mode 100644 src/input/PointerTracker.h create mode 100644 tests/tst_alternatespicker.cpp create mode 100644 tests/tst_keyboardsurface.cpp create mode 100644 tests/tst_pointertracker.cpp diff --git a/qml/AlternatesPopup.qml b/qml/AlternatesPopup.qml new file mode 100644 index 0000000..5ca89ba --- /dev/null +++ b/qml/AlternatesPopup.qml @@ -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 + } + } + } + } +} diff --git a/qml/Key.qml b/qml/Key.qml index 0306f2d..d19b441 100644 --- a/qml/Key.qml +++ b/qml/Key.qml @@ -13,7 +13,9 @@ Item { required property real keyWidth required property real keyHeight required property bool pressed + required property real flickProgress 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 shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off @@ -52,13 +54,18 @@ Item { color: key.lit ? Theme.characterKey : Theme.functionKey } + // Flicking slides the secondary symbol down into the main position. Text { 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 - anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: key.height * 0.1 } - font.pixelSize: key.height * 0.24 - color: Theme.secondaryText + anchors.horizontalCenter: parent.horizontalCenter + y: restY + (centerY - restY) * key.flickProgress + font.pixelSize: key.height * (0.24 + 0.16 * key.flickProgress) + color: key.flickProgress >= 1 ? Theme.text : Theme.secondaryText } Text { @@ -70,5 +77,7 @@ Item { } font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4) color: Theme.text + visible: !key.blank + opacity: 1 - key.flickProgress } } diff --git a/qml/Keyboard.qml b/qml/Keyboard.qml index 7593bde..79b8802 100644 --- a/qml/Keyboard.qml +++ b/qml/Keyboard.qml @@ -8,6 +8,12 @@ KeyboardSurface { model: surface.model delegate: Key { shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off + blank: surface.trackpadActive } } + + AlternatesPopup { + picker: surface.alternates + z: 1 + } } diff --git a/qml/Main.qml b/qml/Main.qml index 96d7f07..ddeaa21 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -8,17 +8,29 @@ Window { required property KeyboardController controller 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 - height: 340 + height: 340 + popupMargin visible: false color: "transparent" + onInputRegionChanged: placement?.setInputRegion(inputRegion) + onPlacementChanged: placement?.setInputRegion(inputRegion) + Rectangle { - anchors.fill: parent + id: panel + anchors { fill: parent; topMargin: root.popupMargin } radius: Theme.windowRadius color: Theme.background Keyboard { + id: keyboard anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top } anchors.margins: 2 model: root.layoutModel diff --git a/qml/Theme.qml b/qml/Theme.qml index b905bd6..d7c5af6 100644 --- a/qml/Theme.qml +++ b/qml/Theme.qml @@ -13,6 +13,8 @@ QtObject { readonly property color text: dark ? "#ffffff" : "#000000" readonly property color secondaryText: dark ? "#a5a5aa" : "#8a8a8e" 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 keyRadius: 6 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3e81d55..819939f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 ) diff --git a/src/input/AlternatesPicker.cpp b/src/input/AlternatesPicker.cpp new file mode 100644 index 0000000..e134bf4 --- /dev/null +++ b/src/input/AlternatesPicker.cpp @@ -0,0 +1,37 @@ +#include "AlternatesPicker.h" + +#include + +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(); +} diff --git a/src/input/AlternatesPicker.h b/src/input/AlternatesPicker.h new file mode 100644 index 0000000..7d775a0 --- /dev/null +++ b/src/input/AlternatesPicker.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +// 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; +}; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index e1858dc..8d15ea8 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -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(); diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h index 86a9bb7..dd2dd6b 100644 --- a/src/input/KeyboardController.h +++ b/src/input/KeyboardController.h @@ -4,6 +4,7 @@ #include "layout/Layout.h" #include +#include #include 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; }; diff --git a/src/input/KeyboardSurface.cpp b/src/input/KeyboardSurface.cpp index db176d2..db5c989 100644 --- a/src/input/KeyboardSurface.cpp +++ b/src/input/KeyboardSurface.cpp @@ -1,12 +1,22 @@ #include "KeyboardSurface.h" -#include "KeyboardController.h" -#include "layout/LayoutModel.h" +#include 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; + } } diff --git a/src/input/KeyboardSurface.h b/src/input/KeyboardSurface.h index c9d0028..e6d33ea 100644 --- a/src/input/KeyboardSurface.h +++ b/src/input/KeyboardSurface.h @@ -1,22 +1,26 @@ #pragma once +#include "AlternatesPicker.h" #include "KeyboardController.h" -#include "layout/Layout.h" +#include "PointerTracker.h" #include "layout/LayoutModel.h" #include #include #include -// 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 m_model; QPointer m_controller; + AlternatesPicker m_alternates; QHash m_pointers; + quint64 m_nextSerial = 1; + bool m_trackpadActive = false; }; diff --git a/src/input/PointerTracker.cpp b/src/input/PointerTracker.cpp new file mode 100644 index 0000000..a1081a7 --- /dev/null +++ b/src/input/PointerTracker.cpp @@ -0,0 +1,70 @@ +#include "PointerTracker.h" + +#include + +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; +} diff --git a/src/input/PointerTracker.h b/src/input/PointerTracker.h new file mode 100644 index 0000000..8c1ad73 --- /dev/null +++ b/src/input/PointerTracker.h @@ -0,0 +1,59 @@ +#pragma once + +#include "layout/Layout.h" + +#include +#include + +// 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; +}; diff --git a/src/layout/LayoutModel.cpp b/src/layout/LayoutModel.cpp index 2c333d0..6cba195 100644 --- a/src/layout/LayoutModel.cpp +++ b/src/layout/LayoutModel.cpp @@ -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 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(m_keys.size(), 0); + m_flickProgress = QList(m_keys.size(), 0.0); relayout(); endResetModel(); } diff --git a/src/layout/LayoutModel.h b/src/layout/LayoutModel.h index 91bcfb7..fe37309 100644 --- a/src/layout/LayoutModel.h +++ b/src/layout/LayoutModel.h @@ -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 m_keys; QList m_rects; QList m_pressCount; // several fingers may hold one key + QList m_flickProgress; }; diff --git a/src/window/FloatingWindow.cpp b/src/window/FloatingWindow.cpp index 0313322..dbc333c 100644 --- a/src/window/FloatingWindow.cpp +++ b/src/window/FloatingWindow.cpp @@ -51,6 +51,11 @@ void FloatingWindow::moveBy(qreal dx, qreal dy) setPosition(m_position + QPoint(qRound(dx), qRound(dy))); } +void FloatingWindow::setInputRegion(const QRectF ®ion) +{ + m_window->setMask(QRegion(region.toAlignedRect())); +} + void FloatingWindow::show() { m_window->setVisible(true); diff --git a/src/window/FloatingWindow.h b/src/window/FloatingWindow.h index f8427e5..157114c 100644 --- a/src/window/FloatingWindow.h +++ b/src/window/FloatingWindow.h @@ -2,6 +2,7 @@ #include #include +#include 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 ®ion); Q_INVOKABLE void show(); Q_INVOKABLE void hide(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e5e07b..04b413d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,3 +9,10 @@ sloposk_add_test(tst_shiftstate) sloposk_add_test(tst_layoutloader) sloposk_add_test(tst_keygeometry) 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") diff --git a/tests/tst_alternatespicker.cpp b/tests/tst_alternatespicker.cpp new file mode 100644 index 0000000..977ef25 --- /dev/null +++ b/tests/tst_alternatespicker.cpp @@ -0,0 +1,52 @@ +#include "input/AlternatesPicker.h" + +#include + +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" diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index 922b6a6..8d1c216 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -1,6 +1,5 @@ #include "engine/InputEngine.h" #include "input/KeyboardController.h" -#include "layout/LayoutLoader.h" #include "layout/LayoutModel.h" #include "layout/LayoutRepository.h" @@ -106,6 +105,22 @@ private Q_SLOTS: 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{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Up})); + } + void globeCyclesLayouts() { QCOMPARE(model.layoutName(), u"English (US)"); diff --git a/tests/tst_keyboardsurface.cpp b/tests/tst_keyboardsurface.cpp new file mode 100644 index 0000000..57656fb --- /dev/null +++ b/tests/tst_keyboardsurface.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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 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 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(engine, model, repo); + + QQmlComponent component(&qml, "Sloposk", "Keyboard"); + keyboard = qobject_cast(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(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{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Right})); + } +}; + +QTEST_MAIN(TestKeyboardSurface) +#include "tst_keyboardsurface.moc" diff --git a/tests/tst_pointertracker.cpp b/tests/tst_pointertracker.cpp new file mode 100644 index 0000000..583441c --- /dev/null +++ b/tests/tst_pointertracker.cpp @@ -0,0 +1,123 @@ +#include "input/PointerTracker.h" + +#include + +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"