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:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ®ion)
|
||||||
|
{
|
||||||
|
m_window->setMask(QRegion(region.toAlignedRect()));
|
||||||
|
}
|
||||||
|
|
||||||
void FloatingWindow::show()
|
void FloatingWindow::show()
|
||||||
{
|
{
|
||||||
m_window->setVisible(true);
|
m_window->setVisible(true);
|
||||||
|
|||||||
@@ -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 ®ion);
|
||||||
Q_INVOKABLE void show();
|
Q_INVOKABLE void show();
|
||||||
Q_INVOKABLE void hide();
|
Q_INVOKABLE void hide();
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user