Fixed quickbinding bug.
This commit is contained in:
@@ -50,3 +50,5 @@ Thumbs.db
|
||||
*.tar.gz
|
||||
*.tar.bz2
|
||||
*.zip
|
||||
*.tar.xz
|
||||
*.tar.zst
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "bindingdialog.h"
|
||||
#include "keycode.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QApplication>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPointer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWheelEvent>
|
||||
|
||||
BindingDialog::BindingDialog(const QString &controlName, bool acceptMouse, QWidget *parent)
|
||||
: QDialog(parent), allowMouse(acceptMouse), recording(false), controlHeld(false) {
|
||||
setModal(true);
|
||||
setWindowTitle(tr("Record binding for %1").arg(controlName));
|
||||
|
||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||
instructions = new QLabel(this);
|
||||
instructions->setWordWrap(true);
|
||||
instructions->setAccessibleName(tr("Recording instructions"));
|
||||
layout->addWidget(instructions);
|
||||
|
||||
summaryLabel = new QLabel(tr("&Sequence:"), this);
|
||||
summaryEdit = new QLineEdit(this);
|
||||
summaryEdit->setReadOnly(true);
|
||||
summaryEdit->setAccessibleName(tr("Recorded sequence"));
|
||||
summaryEdit->setAccessibleDescription(tr("Read-only summary of the keyboard sequence or mouse action."));
|
||||
summaryLabel->setBuddy(summaryEdit);
|
||||
layout->addWidget(summaryLabel);
|
||||
layout->addWidget(summaryEdit);
|
||||
|
||||
QHBoxLayout *delayLayout = new QHBoxLayout;
|
||||
delayLabel = new QLabel(tr("Step &delay:"), this);
|
||||
delaySpin = new QSpinBox(this);
|
||||
delaySpin->setRange(InputBinding::MinimumDelay, InputBinding::MaximumDelay);
|
||||
delaySpin->setSuffix(tr(" ms"));
|
||||
delaySpin->setValue(InputBinding::DefaultDelay);
|
||||
delaySpin->setAccessibleName(tr("Sequence step delay"));
|
||||
delaySpin->setAccessibleDescription(tr("Time from the start of one chord to the start of the next chord."));
|
||||
delayLabel->setBuddy(delaySpin);
|
||||
delayLayout->addWidget(delayLabel);
|
||||
delayLayout->addWidget(delaySpin);
|
||||
layout->addLayout(delayLayout);
|
||||
|
||||
QHBoxLayout *actions = new QHBoxLayout;
|
||||
useButton = new QPushButton(tr("&Use"), this);
|
||||
againButton = new QPushButton(tr("Record &Again"), this);
|
||||
clearButton = new QPushButton(tr("C&lear"), this);
|
||||
cancelButton = new QPushButton(tr("&Cancel"), this);
|
||||
useButton->setAccessibleDescription(tr("Use this binding and close the recorder."));
|
||||
againButton->setAccessibleDescription(tr("Discard this recording and start recording again."));
|
||||
clearButton->setAccessibleDescription(tr("Remove the binding from this controller control."));
|
||||
cancelButton->setAccessibleDescription(tr("Close without changing the binding."));
|
||||
actions->addWidget(useButton);
|
||||
actions->addWidget(againButton);
|
||||
actions->addWidget(clearButton);
|
||||
actions->addWidget(cancelButton);
|
||||
layout->addLayout(actions);
|
||||
|
||||
connect(useButton, &QPushButton::clicked, this, &BindingDialog::useBinding);
|
||||
connect(againButton, &QPushButton::clicked, this, &BindingDialog::recordAgain);
|
||||
connect(clearButton, &QPushButton::clicked, this, &BindingDialog::clearBinding);
|
||||
connect(cancelButton, &QPushButton::clicked, this, &BindingDialog::reject);
|
||||
qApp->installEventFilter(this);
|
||||
beginRecording();
|
||||
}
|
||||
|
||||
bool BindingDialog::eventFilter(QObject *watched, QEvent *event) {
|
||||
Q_UNUSED(watched);
|
||||
if (!recording) return false;
|
||||
if (event->type() == QEvent::KeyPress) {
|
||||
keyPressEvent(static_cast<QKeyEvent *>(event));
|
||||
return true;
|
||||
}
|
||||
if (event->type() == QEvent::KeyRelease) {
|
||||
keyReleaseEvent(static_cast<QKeyEvent *>(event));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BindingDialog::getBinding(const QString &controlName, const InputBinding &initial,
|
||||
InputBinding *binding, bool acceptMouse, QWidget *parent) {
|
||||
QPointer<BindingDialog> dialog = new BindingDialog(controlName, acceptMouse, parent);
|
||||
dialog->recordedBinding.stepDelay = initial.stepDelay;
|
||||
dialog->delaySpin->setValue(initial.stepDelay);
|
||||
const bool accepted = dialog->exec() == QDialog::Accepted;
|
||||
if (accepted && dialog && binding) *binding = dialog->recordedBinding;
|
||||
delete dialog;
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void BindingDialog::beginRecording() {
|
||||
recordedBinding = InputBinding();
|
||||
recordedBinding.stepDelay = delaySpin->value();
|
||||
currentChord.clear();
|
||||
keysDown.clear();
|
||||
controlHeld = false;
|
||||
recording = true;
|
||||
instructions->setText(allowMouse
|
||||
? tr("Recording. Tap keys for separate steps or hold keys together for a chord. A mouse button or wheel action may be used only as the first action. Press and hold Ctrl, then press Enter to finish. To leave without changing the binding, finish recording and choose Cancel on the review screen.")
|
||||
: tr("Recording. Tap keys for separate steps or hold keys together for a chord. Press and hold Ctrl, then press Enter to finish. To leave without changing the binding, finish recording and choose Cancel on the review screen."));
|
||||
summaryLabel->hide();
|
||||
summaryEdit->hide();
|
||||
delayLabel->hide();
|
||||
delaySpin->hide();
|
||||
useButton->hide();
|
||||
againButton->hide();
|
||||
clearButton->hide();
|
||||
cancelButton->hide();
|
||||
setFocus(Qt::OtherFocusReason);
|
||||
}
|
||||
|
||||
void BindingDialog::finishRecording() {
|
||||
if (!currentChord.isEmpty() && keysDown.isEmpty()) commitChord();
|
||||
recording = false;
|
||||
recordedBinding.stepDelay = delaySpin->value();
|
||||
instructions->setText(tr("Review the recorded binding. Adjust the delay if this sequence has more than one step."));
|
||||
summaryEdit->setText(recordedBinding.summary());
|
||||
summaryLabel->show();
|
||||
summaryEdit->show();
|
||||
delayLabel->show();
|
||||
delaySpin->show();
|
||||
const bool hasSteps = !recordedBinding.isEmpty();
|
||||
const bool multipleSteps = recordedBinding.kind == InputBinding::Keyboard && recordedBinding.chords.size() > 1;
|
||||
delaySpin->setEnabled(multipleSteps);
|
||||
delaySpin->setAccessibleDescription(multipleSteps
|
||||
? tr("Time from the start of one chord to the start of the next chord.")
|
||||
: tr("Step delay is unavailable because this binding does not have multiple steps."));
|
||||
useButton->setEnabled(hasSteps);
|
||||
useButton->show();
|
||||
againButton->show();
|
||||
clearButton->show();
|
||||
cancelButton->show();
|
||||
setTabOrder(summaryEdit, delaySpin);
|
||||
setTabOrder(delaySpin, useButton);
|
||||
setTabOrder(useButton, againButton);
|
||||
setTabOrder(againButton, clearButton);
|
||||
setTabOrder(clearButton, cancelButton);
|
||||
summaryEdit->setFocus(Qt::OtherFocusReason);
|
||||
}
|
||||
|
||||
void BindingDialog::commitChord() {
|
||||
if (currentChord.isEmpty() || recordedBinding.chords.size() >= InputBinding::MaximumChords) return;
|
||||
recordedBinding.kind = InputBinding::Keyboard;
|
||||
recordedBinding.chords.append(KeyChord{currentChord});
|
||||
currentChord.clear();
|
||||
if (recordedBinding.chords.size() == InputBinding::MaximumChords) finishRecording();
|
||||
}
|
||||
|
||||
void BindingDialog::keyPressEvent(QKeyEvent *event) {
|
||||
if (!recording) {
|
||||
QDialog::keyPressEvent(event);
|
||||
return;
|
||||
}
|
||||
if (event->isAutoRepeat()) return;
|
||||
const int keycode = static_cast<int>(event->nativeScanCode());
|
||||
if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) {
|
||||
if (controlHeld) {
|
||||
currentChord.clear();
|
||||
keysDown.clear();
|
||||
finishRecording();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keycode <= 0 || keysDown.contains(keycode)) return;
|
||||
if (event->key() == Qt::Key_Control) controlHeld = true;
|
||||
keysDown.insert(keycode);
|
||||
currentChord.append(keycode);
|
||||
}
|
||||
|
||||
void BindingDialog::keyReleaseEvent(QKeyEvent *event) {
|
||||
if (!recording) {
|
||||
QDialog::keyReleaseEvent(event);
|
||||
return;
|
||||
}
|
||||
if (event->isAutoRepeat()) return;
|
||||
if (event->key() == Qt::Key_Control) controlHeld = false;
|
||||
keysDown.remove(static_cast<int>(event->nativeScanCode()));
|
||||
if (keysDown.isEmpty()) commitChord();
|
||||
}
|
||||
|
||||
static int mouseButtonNumber(Qt::MouseButton button) {
|
||||
if (button == Qt::LeftButton) return 1;
|
||||
if (button == Qt::MiddleButton) return 2;
|
||||
if (button == Qt::RightButton) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void BindingDialog::mouseReleaseEvent(QMouseEvent *event) {
|
||||
if (!recording || !allowMouse || !recordedBinding.chords.isEmpty() || !currentChord.isEmpty()) {
|
||||
QDialog::mouseReleaseEvent(event);
|
||||
return;
|
||||
}
|
||||
const int button = mouseButtonNumber(event->button());
|
||||
if (button == 0) return;
|
||||
recordedBinding = InputBinding::mouse(button);
|
||||
finishRecording();
|
||||
}
|
||||
|
||||
void BindingDialog::wheelEvent(QWheelEvent *event) {
|
||||
if (!recording || !allowMouse || !recordedBinding.chords.isEmpty() || !currentChord.isEmpty()) return;
|
||||
const QPoint delta = event->angleDelta();
|
||||
int button = 0;
|
||||
if (delta.y() < 0) button = 4;
|
||||
else if (delta.y() > 0) button = 5;
|
||||
else if (delta.x() < 0) button = 6;
|
||||
else if (delta.x() > 0) button = 7;
|
||||
if (button == 0) return;
|
||||
recordedBinding = InputBinding::mouse(button);
|
||||
finishRecording();
|
||||
}
|
||||
|
||||
void BindingDialog::useBinding() {
|
||||
recordedBinding.stepDelay = delaySpin->value();
|
||||
accept();
|
||||
}
|
||||
|
||||
void BindingDialog::recordAgain() { beginRecording(); }
|
||||
|
||||
void BindingDialog::clearBinding() {
|
||||
recordedBinding = InputBinding();
|
||||
accept();
|
||||
}
|
||||
|
||||
BindingButton::BindingButton(const QString &name, const InputBinding &binding,
|
||||
QWidget *parent, bool acceptMouse)
|
||||
: QPushButton(parent), controlName(name), currentBinding(binding), allowMouse(acceptMouse) {
|
||||
setAccessibleName(tr("Binding for %1").arg(controlName));
|
||||
setAccessibleDescription(tr("Opens the keyboard sequence and mouse binding recorder."));
|
||||
setText(currentBinding.summary());
|
||||
connect(this, &QPushButton::clicked, this, &BindingButton::chooseBinding);
|
||||
}
|
||||
|
||||
void BindingButton::setBinding(const InputBinding &binding) {
|
||||
currentBinding = binding;
|
||||
setText(currentBinding.summary());
|
||||
emit bindingChanged(currentBinding);
|
||||
}
|
||||
|
||||
void BindingButton::chooseBinding() {
|
||||
InputBinding chosen;
|
||||
if (BindingDialog::getBinding(controlName, currentBinding, &chosen, allowMouse, window())) {
|
||||
setBinding(chosen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#ifndef THUNDERPAD_BINDINGDIALOG_H
|
||||
#define THUNDERPAD_BINDINGDIALOG_H
|
||||
|
||||
#include "inputbinding.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSet>
|
||||
#include <QSpinBox>
|
||||
|
||||
class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
class QWheelEvent;
|
||||
|
||||
class BindingDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit BindingDialog(const QString &controlName, bool acceptMouse = true,
|
||||
QWidget *parent = nullptr);
|
||||
static bool getBinding(const QString &controlName, const InputBinding &initial,
|
||||
InputBinding *binding, bool acceptMouse = true,
|
||||
QWidget *parent = nullptr);
|
||||
InputBinding binding() const { return recordedBinding; }
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void keyReleaseEvent(QKeyEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void wheelEvent(QWheelEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void useBinding();
|
||||
void recordAgain();
|
||||
void clearBinding();
|
||||
|
||||
private:
|
||||
void beginRecording();
|
||||
void finishRecording();
|
||||
void commitChord();
|
||||
|
||||
bool allowMouse;
|
||||
bool recording;
|
||||
InputBinding recordedBinding;
|
||||
QVector<int> currentChord;
|
||||
QSet<int> keysDown;
|
||||
bool controlHeld;
|
||||
QLabel *instructions;
|
||||
QLabel *summaryLabel;
|
||||
QLineEdit *summaryEdit;
|
||||
QLabel *delayLabel;
|
||||
QSpinBox *delaySpin;
|
||||
QPushButton *useButton;
|
||||
QPushButton *againButton;
|
||||
QPushButton *clearButton;
|
||||
QPushButton *cancelButton;
|
||||
};
|
||||
|
||||
class BindingButton : public QPushButton {
|
||||
Q_OBJECT
|
||||
public:
|
||||
BindingButton(const QString &controlName, const InputBinding &binding,
|
||||
QWidget *parent, bool acceptMouse = true);
|
||||
InputBinding binding() const { return currentBinding; }
|
||||
void setBinding(const InputBinding &binding);
|
||||
|
||||
signals:
|
||||
void bindingChanged(const InputBinding &binding);
|
||||
|
||||
private slots:
|
||||
void chooseBinding();
|
||||
|
||||
private:
|
||||
QString controlName;
|
||||
InputBinding currentBinding;
|
||||
bool allowMouse;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,217 @@
|
||||
#include "inputbinding.h"
|
||||
|
||||
#include "constant.h"
|
||||
#include "keycode.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
|
||||
const int InputBinding::DefaultDelay;
|
||||
const int InputBinding::MinimumDelay;
|
||||
const int InputBinding::MaximumDelay;
|
||||
const int InputBinding::MaximumChords;
|
||||
|
||||
InputBinding::InputBinding()
|
||||
: kind(NoBinding), mouseButton(0), stepDelay(DefaultDelay) {}
|
||||
|
||||
InputBinding InputBinding::key(int keycode) {
|
||||
InputBinding result;
|
||||
if (keycode > 0) {
|
||||
result.kind = Keyboard;
|
||||
result.chords.append(KeyChord{{keycode}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
InputBinding InputBinding::mouse(int button) {
|
||||
InputBinding result;
|
||||
if (button > 0) {
|
||||
result.kind = Mouse;
|
||||
result.mouseButton = button;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool InputBinding::parseSequence(const QString &delayText, const QString &sequenceText,
|
||||
InputBinding *binding, QString *error) {
|
||||
bool delayOkay = false;
|
||||
const int delay = delayText.toInt(&delayOkay);
|
||||
if (!delayOkay || delay < MinimumDelay || delay > MaximumDelay) {
|
||||
if (error) *error = QObject::tr("Sequence delay must be between %1 and %2 milliseconds.")
|
||||
.arg(MinimumDelay).arg(MaximumDelay);
|
||||
return false;
|
||||
}
|
||||
if (sequenceText.isEmpty() || sequenceText.startsWith('/') || sequenceText.endsWith('/') ||
|
||||
sequenceText.contains("//")) {
|
||||
if (error) *error = QObject::tr("Sequence contains an empty chord or malformed '/'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList chordTexts = sequenceText.split('/');
|
||||
if (chordTexts.size() > MaximumChords) {
|
||||
if (error) *error = QObject::tr("Sequence has more than %1 chords.").arg(MaximumChords);
|
||||
return false;
|
||||
}
|
||||
|
||||
InputBinding result;
|
||||
result.kind = Keyboard;
|
||||
result.stepDelay = delay;
|
||||
for (const QString &chordText : chordTexts) {
|
||||
if (chordText.isEmpty() || chordText.startsWith('+') || chordText.endsWith('+') ||
|
||||
chordText.contains("++")) {
|
||||
if (error) *error = QObject::tr("Sequence contains an empty key or malformed '+'.");
|
||||
return false;
|
||||
}
|
||||
KeyChord chord;
|
||||
QSet<int> seen;
|
||||
for (const QString &keyText : chordText.split('+')) {
|
||||
bool keyOkay = false;
|
||||
const int keycode = keyText.toInt(&keyOkay);
|
||||
if (!keyOkay || keycode < 1 || keycode > MAXKEY) {
|
||||
if (error) *error = QObject::tr("Sequence keycode '%1' must be between 1 and %2.")
|
||||
.arg(keyText).arg(MAXKEY);
|
||||
return false;
|
||||
}
|
||||
if (seen.contains(keycode)) {
|
||||
if (error) *error = QObject::tr("Sequence chord contains duplicate keycode %1.").arg(keycode);
|
||||
return false;
|
||||
}
|
||||
seen.insert(keycode);
|
||||
chord.keycodes.append(keycode);
|
||||
}
|
||||
result.chords.append(chord);
|
||||
}
|
||||
if (!result.isValid(error)) return false;
|
||||
if (binding) *binding = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InputBinding::isEmpty() const { return kind == NoBinding; }
|
||||
bool InputBinding::isPlainKey() const {
|
||||
return kind == Keyboard && chords.size() == 1 && chords.first().keycodes.size() == 1;
|
||||
}
|
||||
bool InputBinding::isComposite() const { return kind == Keyboard && !isPlainKey() && !chords.isEmpty(); }
|
||||
bool InputBinding::isMouse() const { return kind == Mouse; }
|
||||
|
||||
bool InputBinding::isValid(QString *error) const {
|
||||
if (kind == NoBinding) return true;
|
||||
if (kind == Mouse) {
|
||||
if (mouseButton >= 1 && mouseButton <= MAXKEY && chords.isEmpty()) return true;
|
||||
if (error) *error = QObject::tr("Mouse bindings must contain one valid mouse button and no keyboard chords.");
|
||||
return false;
|
||||
}
|
||||
if (kind != Keyboard || chords.isEmpty() || chords.size() > MaximumChords ||
|
||||
stepDelay < MinimumDelay || stepDelay > MaximumDelay) {
|
||||
if (error) *error = QObject::tr("Keyboard sequence has an invalid delay or chord count.");
|
||||
return false;
|
||||
}
|
||||
for (const KeyChord &chord : chords) {
|
||||
if (chord.keycodes.isEmpty()) {
|
||||
if (error) *error = QObject::tr("Keyboard sequence contains an empty chord.");
|
||||
return false;
|
||||
}
|
||||
QSet<int> seen;
|
||||
for (int keycode : chord.keycodes) {
|
||||
if (keycode < 1 || keycode > MAXKEY || seen.contains(keycode)) {
|
||||
if (error) *error = QObject::tr("Keyboard sequence contains an invalid or duplicate keycode.");
|
||||
return false;
|
||||
}
|
||||
seen.insert(keycode);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString InputBinding::sequenceToken() const {
|
||||
QStringList chordTexts;
|
||||
for (const KeyChord &chord : chords) {
|
||||
QStringList keys;
|
||||
for (int keycode : chord.keycodes) keys.append(QString::number(keycode));
|
||||
chordTexts.append(keys.join('+'));
|
||||
}
|
||||
return chordTexts.join('/');
|
||||
}
|
||||
|
||||
QString InputBinding::summary() const {
|
||||
if (kind == NoBinding) return QObject::tr("[NO KEY]");
|
||||
if (kind == Mouse) return QObject::tr("Mouse %1").arg(mouseButton);
|
||||
QStringList steps;
|
||||
for (const KeyChord &chord : chords) {
|
||||
QStringList keys;
|
||||
bool hasShift = false;
|
||||
for (int keycode : chord.keycodes) hasShift = hasShift || keycodeIsShift(keycode);
|
||||
for (int keycode : chord.keycodes) {
|
||||
const QString modifier = modifierKeyName(keycode);
|
||||
keys.append(modifier.isEmpty() ? ktos(keycode) : modifier);
|
||||
}
|
||||
QString text = keys.join('+');
|
||||
if (hasShift) {
|
||||
for (auto it = chord.keycodes.crbegin(); it != chord.keycodes.crend(); ++it) {
|
||||
if (keycodeIsShift(*it)) continue;
|
||||
const QString symbol = shiftedKeyName(*it);
|
||||
if (!symbol.isEmpty() && symbol != ktos(*it)) text += QObject::tr(" (%1)").arg(symbol);
|
||||
break;
|
||||
}
|
||||
}
|
||||
steps.append(text);
|
||||
}
|
||||
return steps.join(QObject::tr(", then "));
|
||||
}
|
||||
|
||||
SequenceRunner::SequenceRunner(QObject *parent)
|
||||
: QObject(parent), chordIndex(0), releasing(false), running(false) {
|
||||
timer.setSingleShot(true);
|
||||
timer.setTimerType(Qt::PreciseTimer);
|
||||
connect(&timer, &QTimer::timeout, this, &SequenceRunner::advance);
|
||||
}
|
||||
|
||||
bool SequenceRunner::start(const InputBinding &binding) {
|
||||
if (running || !binding.isComposite()) return false;
|
||||
activeBinding = binding;
|
||||
chordIndex = 0;
|
||||
releasing = false;
|
||||
running = true;
|
||||
pressCurrentChord();
|
||||
timer.start(25);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SequenceRunner::cancel() {
|
||||
timer.stop();
|
||||
if (!pressedKeys.isEmpty()) releaseCurrentChord();
|
||||
running = false;
|
||||
chordIndex = 0;
|
||||
releasing = false;
|
||||
}
|
||||
|
||||
void SequenceRunner::pressCurrentChord() {
|
||||
pressedKeys.clear();
|
||||
for (int keycode : activeBinding.chords.at(chordIndex).keycodes) {
|
||||
emit keyEvent(keycode, true);
|
||||
pressedKeys.append(keycode);
|
||||
}
|
||||
}
|
||||
|
||||
void SequenceRunner::releaseCurrentChord() {
|
||||
for (auto it = pressedKeys.crbegin(); it != pressedKeys.crend(); ++it) emit keyEvent(*it, false);
|
||||
pressedKeys.clear();
|
||||
}
|
||||
|
||||
void SequenceRunner::advance() {
|
||||
if (!running) return;
|
||||
if (!releasing) {
|
||||
releaseCurrentChord();
|
||||
releasing = true;
|
||||
if (chordIndex + 1 >= activeBinding.chords.size()) {
|
||||
running = false;
|
||||
emit finished();
|
||||
return;
|
||||
}
|
||||
timer.start(activeBinding.stepDelay - 25);
|
||||
return;
|
||||
}
|
||||
++chordIndex;
|
||||
releasing = false;
|
||||
pressCurrentChord();
|
||||
timer.start(25);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#ifndef THUNDERPAD_INPUTBINDING_H
|
||||
#define THUNDERPAD_INPUTBINDING_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
|
||||
struct KeyChord {
|
||||
QVector<int> keycodes;
|
||||
|
||||
bool operator==(const KeyChord &other) const { return keycodes == other.keycodes; }
|
||||
};
|
||||
|
||||
class InputBinding {
|
||||
public:
|
||||
enum Kind { NoBinding, Keyboard, Mouse };
|
||||
static const int DefaultDelay = 100;
|
||||
static const int MinimumDelay = 50;
|
||||
static const int MaximumDelay = 1000;
|
||||
static const int MaximumChords = 32;
|
||||
|
||||
InputBinding();
|
||||
|
||||
static InputBinding key(int keycode);
|
||||
static InputBinding mouse(int button);
|
||||
static bool parseSequence(const QString &delayText, const QString &sequenceText,
|
||||
InputBinding *binding, QString *error = nullptr);
|
||||
|
||||
bool isEmpty() const;
|
||||
bool isPlainKey() const;
|
||||
bool isComposite() const;
|
||||
bool isMouse() const;
|
||||
bool isValid(QString *error = nullptr) const;
|
||||
QString sequenceToken() const;
|
||||
QString summary() const;
|
||||
|
||||
Kind kind;
|
||||
QVector<KeyChord> chords;
|
||||
int mouseButton;
|
||||
int stepDelay;
|
||||
};
|
||||
|
||||
class SequenceRunner : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SequenceRunner(QObject *parent = nullptr);
|
||||
bool start(const InputBinding &binding);
|
||||
void cancel();
|
||||
bool isRunning() const { return running; }
|
||||
|
||||
signals:
|
||||
void keyEvent(int keycode, bool press);
|
||||
void finished();
|
||||
|
||||
private slots:
|
||||
void advance();
|
||||
|
||||
private:
|
||||
void pressCurrentChord();
|
||||
void releaseCurrentChord();
|
||||
|
||||
QTimer timer;
|
||||
InputBinding activeBinding;
|
||||
QVector<int> pressedKeys;
|
||||
int chordIndex;
|
||||
bool releasing;
|
||||
bool running;
|
||||
};
|
||||
|
||||
#endif
|
||||
+1
-1
@@ -219,7 +219,7 @@ void JoyPad::release() {
|
||||
|
||||
void JoyPad::jsevent(const js_event &msg) {
|
||||
//if there is a JoyPadWidget around, ie, if the joypad is being edited
|
||||
if (jpw != NULL && hasFocus) {
|
||||
if (jpw != NULL && (hasFocus || jpw->isQuickSetActive())) {
|
||||
//tell the dialog there was an event. It will use this to flash
|
||||
//the appropriate button, if necesary.
|
||||
jpw->jsevent(msg);
|
||||
|
||||
@@ -115,6 +115,10 @@ JoyPadWidget::~JoyPadWidget() {
|
||||
joypad->releaseWidget();
|
||||
}
|
||||
|
||||
bool JoyPadWidget::isQuickSetActive() const {
|
||||
return quickset != NULL;
|
||||
}
|
||||
|
||||
void JoyPadWidget::flash( bool on ) {
|
||||
//true iff this entire widget was considered "flashed" before
|
||||
bool wasOn = (flashcount != 0);
|
||||
|
||||
@@ -34,6 +34,8 @@ class JoyPadWidget : public QWidget {
|
||||
void jsevent(const js_event &msg );
|
||||
//Propagate changes in layout list
|
||||
void updateButtonLayoutLists(const QStringList layoutNames);
|
||||
//Quick Set must keep receiving controller events while its modal dialog is active.
|
||||
bool isQuickSetActive() const;
|
||||
public slots:
|
||||
//called whenever one of the subwidgets flashes... used to determine
|
||||
//when to emit the flashed() signal.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
set(binding_test_SOURCES
|
||||
test_bindings.cpp
|
||||
../src/axis.cpp
|
||||
../src/bindingdialog.cpp
|
||||
../src/button.cpp
|
||||
../src/event.cpp
|
||||
../src/inputbinding.cpp
|
||||
../src/keycode.cpp
|
||||
../src/keydialog.cpp)
|
||||
|
||||
set(binding_test_QOBJECT_HEADERS
|
||||
../src/axis.h
|
||||
../src/bindingdialog.h
|
||||
../src/button.h
|
||||
../src/inputbinding.h
|
||||
../src/keycode.h
|
||||
../src/keydialog.hpp)
|
||||
|
||||
qt6_wrap_cpp(binding_test_MOC ${binding_test_QOBJECT_HEADERS})
|
||||
add_executable(binding_tests ${binding_test_SOURCES} ${binding_test_MOC})
|
||||
set_target_properties(binding_tests PROPERTIES AUTOMOC ON)
|
||||
target_include_directories(binding_tests PRIVATE ../src "${PROJECT_BINARY_DIR}/src")
|
||||
target_link_libraries(binding_tests Qt6::Test Qt6::Widgets Qt6::Gui Xtst X11)
|
||||
add_test(NAME binding_tests COMMAND binding_tests)
|
||||
set_tests_properties(binding_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
@@ -0,0 +1,348 @@
|
||||
#include "axis.h"
|
||||
#include "bindingdialog.h"
|
||||
#include "button.h"
|
||||
#include "inputbinding.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QApplication>
|
||||
#include <QKeyEvent>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
#include <QTextStream>
|
||||
|
||||
class RecorderProbe : public BindingDialog {
|
||||
public:
|
||||
RecorderProbe() : BindingDialog("Test control", true) {}
|
||||
|
||||
void press(int qtKey, int scanCode, bool repeat = false) {
|
||||
QKeyEvent event(QEvent::KeyPress, qtKey, Qt::NoModifier,
|
||||
static_cast<quint32>(scanCode), 0, 0, QString(), repeat);
|
||||
keyPressEvent(&event);
|
||||
}
|
||||
void releaseKey(int qtKey, int scanCode, bool repeat = false) {
|
||||
QKeyEvent event(QEvent::KeyRelease, qtKey, Qt::NoModifier,
|
||||
static_cast<quint32>(scanCode), 0, 0, QString(), repeat);
|
||||
keyReleaseEvent(&event);
|
||||
}
|
||||
void tap(int qtKey, int scanCode) {
|
||||
press(qtKey, scanCode);
|
||||
releaseKey(qtKey, scanCode);
|
||||
}
|
||||
void finish() {
|
||||
press(Qt::Key_Control, 37);
|
||||
press(Qt::Key_Return, 36);
|
||||
}
|
||||
void dispatchTap(int qtKey, int scanCode) {
|
||||
QWidget *target = findChild<QPushButton *>();
|
||||
QVERIFY(target);
|
||||
QKeyEvent pressEvent(QEvent::KeyPress, qtKey, Qt::NoModifier,
|
||||
static_cast<quint32>(scanCode), 0, 0);
|
||||
QApplication::sendEvent(target, &pressEvent);
|
||||
QKeyEvent releaseEvent(QEvent::KeyRelease, qtKey, Qt::NoModifier,
|
||||
static_cast<quint32>(scanCode), 0, 0);
|
||||
QApplication::sendEvent(target, &releaseEvent);
|
||||
}
|
||||
bool cancelAvailable() const {
|
||||
for (QPushButton *button : findChildren<QPushButton *>()) {
|
||||
if (button->text().contains("Cancel")) return button->isVisibleTo(this);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
class ButtonProbe : public Button {
|
||||
public:
|
||||
ButtonProbe() : Button(0) {}
|
||||
QVector<bool> plainEvents;
|
||||
QVector<QPair<int, bool>> compositeEvents;
|
||||
protected:
|
||||
void click(bool press) override { plainEvents.append(press); }
|
||||
void compositeKeyEvent(int keycode, bool press) override {
|
||||
compositeEvents.append(qMakePair(keycode, press));
|
||||
}
|
||||
};
|
||||
|
||||
class AxisProbe : public Axis {
|
||||
public:
|
||||
AxisProbe() : Axis(0) {}
|
||||
QVector<QPair<int, bool>> compositeEvents;
|
||||
protected:
|
||||
void compositeKeyEvent(int keycode, bool press) override {
|
||||
compositeEvents.append(qMakePair(keycode, press));
|
||||
}
|
||||
};
|
||||
|
||||
class BindingTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void parseAndSerializeSequences();
|
||||
void rejectInvalidSequences_data();
|
||||
void rejectInvalidSequences();
|
||||
void preserveLegacySerialization();
|
||||
void recorderPlainChordAndSteps();
|
||||
void recorderModifiersRepeatLimitAndFinish();
|
||||
void runnerOrderTimingRetriggerAndCancel();
|
||||
void preservePlainButtonModes();
|
||||
void axisCompositeRearmsWithoutRepeating();
|
||||
};
|
||||
|
||||
void BindingTests::parseAndSerializeSequences() {
|
||||
InputBinding binding;
|
||||
QString error;
|
||||
QVERIFY2(InputBinding::parseSequence("125", "64+28/49/10+14", &binding, &error), qPrintable(error));
|
||||
QCOMPARE(binding.stepDelay, 125);
|
||||
QCOMPARE(binding.chords.size(), 3);
|
||||
QCOMPARE(binding.sequenceToken(), QString("64+28/49/10+14"));
|
||||
|
||||
Button button(0);
|
||||
QString buttonInput("sequence 125 64+28/49");
|
||||
QTextStream buttonRead(&buttonInput);
|
||||
QVERIFY(button.read(buttonRead));
|
||||
QString buttonOutput;
|
||||
QTextStream buttonWrite(&buttonOutput);
|
||||
button.write(buttonWrite);
|
||||
QCOMPARE(buttonOutput, QString("\tButton 1: sequence 125 64+28/49\n"));
|
||||
|
||||
Axis axis(0);
|
||||
QString axisInput("ZeroOne, +sequence 80 64+28/49, -sequence 250 10/11+12");
|
||||
QTextStream axisRead(&axisInput);
|
||||
QVERIFY2(axis.read(axisRead), qPrintable(axis.readError()));
|
||||
QString axisOutput;
|
||||
QTextStream axisWrite(&axisOutput);
|
||||
axis.write(axisWrite);
|
||||
QCOMPARE(axisOutput, QString("\tAxis 1: ZeroOne, +sequence 80 64+28/49, -sequence 250 10/11+12\n"));
|
||||
|
||||
Button malformedButton(0);
|
||||
QString malformedButtonInput("sequence 100 10 / 11");
|
||||
QTextStream malformedButtonRead(&malformedButtonInput);
|
||||
QVERIFY(!malformedButton.read(malformedButtonRead));
|
||||
QVERIFY(!malformedButton.readError().isEmpty());
|
||||
|
||||
Axis malformedAxis(0);
|
||||
QString malformedAxisInput("ZeroOne, +sequence 100 10 + 11, -key 12");
|
||||
QTextStream malformedAxisRead(&malformedAxisInput);
|
||||
QVERIFY(!malformedAxis.read(malformedAxisRead));
|
||||
QVERIFY(!malformedAxis.readError().isEmpty());
|
||||
|
||||
Axis mouseModeSequence(0);
|
||||
QString mouseModeInput("Gradient, +sequence 100 10+11, -key 12, mouse+h");
|
||||
QTextStream mouseModeRead(&mouseModeInput);
|
||||
QVERIFY(!mouseModeSequence.read(mouseModeRead));
|
||||
QVERIFY(mouseModeSequence.readError().contains("mouse movement"));
|
||||
}
|
||||
|
||||
void BindingTests::rejectInvalidSequences_data() {
|
||||
QTest::addColumn<QString>("delay");
|
||||
QTest::addColumn<QString>("sequence");
|
||||
QTest::newRow("delay-low") << "49" << "10/11";
|
||||
QTest::newRow("delay-high") << "1001" << "10/11";
|
||||
QTest::newRow("delay-text") << "fast" << "10/11";
|
||||
QTest::newRow("empty-chord") << "100" << "10//11";
|
||||
QTest::newRow("empty-key") << "100" << "10++11";
|
||||
QTest::newRow("duplicate") << "100" << "10+10";
|
||||
QTest::newRow("keycode-zero") << "100" << "0/11";
|
||||
QTest::newRow("keycode-high") << "100" << "256/11";
|
||||
QStringList tooMany;
|
||||
for (int i = 0; i < 33; ++i) tooMany.append(QString::number(i + 1));
|
||||
QTest::newRow("too-many") << "100" << tooMany.join('/');
|
||||
}
|
||||
|
||||
void BindingTests::rejectInvalidSequences() {
|
||||
QFETCH(QString, delay);
|
||||
QFETCH(QString, sequence);
|
||||
InputBinding binding;
|
||||
QString error;
|
||||
QVERIFY(!InputBinding::parseSequence(delay, sequence, &binding, &error));
|
||||
QVERIFY(!error.isEmpty());
|
||||
|
||||
}
|
||||
|
||||
void BindingTests::preserveLegacySerialization() {
|
||||
Button button(1);
|
||||
QString buttonInput("rapidfire, sticky, key 42");
|
||||
QTextStream buttonRead(&buttonInput);
|
||||
QVERIFY(button.read(buttonRead));
|
||||
QString buttonOutput;
|
||||
QTextStream buttonWrite(&buttonOutput);
|
||||
button.write(buttonWrite);
|
||||
QCOMPARE(buttonOutput, QString("\tButton 2: rapidfire, sticky, key 42\n"));
|
||||
|
||||
Axis axis(2);
|
||||
QString axisInput("Gradient, dZone 4000, xZone 29000, +mouse 1, -key 113");
|
||||
QTextStream axisRead(&axisInput);
|
||||
QVERIFY(axis.read(axisRead));
|
||||
QString axisOutput;
|
||||
QTextStream axisWrite(&axisOutput);
|
||||
axis.write(axisWrite);
|
||||
QCOMPARE(axisOutput, QString("\tAxis 3: Gradient, dZone 4000, xZone 29000, +mouse 1, -key 113\n"));
|
||||
}
|
||||
|
||||
void BindingTests::recorderPlainChordAndSteps() {
|
||||
RecorderProbe plain;
|
||||
plain.tap(Qt::Key_A, 38);
|
||||
plain.finish();
|
||||
QVERIFY(plain.binding().isPlainKey());
|
||||
QCOMPARE(plain.binding().chords.first().keycodes, QVector<int>({38}));
|
||||
|
||||
RecorderProbe chord;
|
||||
chord.press(Qt::Key_Alt, 64);
|
||||
chord.press(Qt::Key_T, 28);
|
||||
chord.releaseKey(Qt::Key_T, 28);
|
||||
chord.releaseKey(Qt::Key_Alt, 64);
|
||||
chord.finish();
|
||||
QCOMPARE(chord.binding().chords, QVector<KeyChord>({KeyChord{{64, 28}}}));
|
||||
|
||||
RecorderProbe steps;
|
||||
steps.tap(Qt::Key_A, 38);
|
||||
steps.tap(Qt::Key_B, 56);
|
||||
steps.finish();
|
||||
QCOMPARE(steps.binding().chords, QVector<KeyChord>({KeyChord{{38}}, KeyChord{{56}}}));
|
||||
|
||||
RecorderProbe tab;
|
||||
tab.dispatchTap(Qt::Key_Tab, 23);
|
||||
tab.finish();
|
||||
QCOMPARE(tab.binding().chords, QVector<KeyChord>({KeyChord{{23}}}));
|
||||
QVERIFY(tab.cancelAvailable());
|
||||
}
|
||||
|
||||
void BindingTests::recorderModifiersRepeatLimitAndFinish() {
|
||||
RecorderProbe modifier;
|
||||
modifier.tap(Qt::Key_Shift, 50);
|
||||
modifier.finish();
|
||||
QCOMPARE(modifier.binding().chords, QVector<KeyChord>({KeyChord{{50}}}));
|
||||
|
||||
RecorderProbe repeat;
|
||||
repeat.press(Qt::Key_A, 38);
|
||||
repeat.press(Qt::Key_A, 38, true);
|
||||
repeat.releaseKey(Qt::Key_A, 38, true);
|
||||
repeat.releaseKey(Qt::Key_A, 38);
|
||||
repeat.finish();
|
||||
QCOMPARE(repeat.binding().chords, QVector<KeyChord>({KeyChord{{38}}}));
|
||||
|
||||
RecorderProbe separateCtrlEnter;
|
||||
separateCtrlEnter.tap(Qt::Key_Control, 37);
|
||||
separateCtrlEnter.tap(Qt::Key_Return, 36);
|
||||
separateCtrlEnter.finish();
|
||||
QCOMPARE(separateCtrlEnter.binding().chords,
|
||||
QVector<KeyChord>({KeyChord{{37}}, KeyChord{{36}}}));
|
||||
|
||||
RecorderProbe limit;
|
||||
for (int i = 0; i < InputBinding::MaximumChords; ++i) limit.tap(Qt::Key_A, 8 + i);
|
||||
QCOMPARE(limit.binding().chords.size(), InputBinding::MaximumChords);
|
||||
}
|
||||
|
||||
void BindingTests::runnerOrderTimingRetriggerAndCancel() {
|
||||
InputBinding binding;
|
||||
QVERIFY(InputBinding::parseSequence("80", "10+11/12+13", &binding));
|
||||
SequenceRunner runner;
|
||||
QVector<QPair<int, bool>> events;
|
||||
QVector<qint64> times;
|
||||
QElapsedTimer elapsed;
|
||||
elapsed.start();
|
||||
connect(&runner, &SequenceRunner::keyEvent, &runner, [&](int keycode, bool press) {
|
||||
events.append(qMakePair(keycode, press));
|
||||
times.append(elapsed.elapsed());
|
||||
});
|
||||
QSignalSpy finished(&runner, &SequenceRunner::finished);
|
||||
QVERIFY(runner.start(binding));
|
||||
QVERIFY(!runner.start(binding));
|
||||
QVERIFY(finished.wait(300));
|
||||
const QVector<QPair<int, bool>> expected = {
|
||||
{10, true}, {11, true}, {11, false}, {10, false},
|
||||
{12, true}, {13, true}, {13, false}, {12, false}
|
||||
};
|
||||
QCOMPARE(events, expected);
|
||||
QVERIFY(times.at(2) >= 20 && times.at(2) <= 60);
|
||||
QVERIFY(times.at(4) >= 65 && times.at(4) <= 120);
|
||||
|
||||
events.clear();
|
||||
QVERIFY(runner.start(binding));
|
||||
runner.cancel();
|
||||
const QVector<QPair<int, bool>> cancelled = {
|
||||
{10, true}, {11, true}, {11, false}, {10, false}
|
||||
};
|
||||
QCOMPARE(events, cancelled);
|
||||
QVERIFY(!runner.isRunning());
|
||||
}
|
||||
|
||||
void BindingTests::preservePlainButtonModes() {
|
||||
ButtonProbe held;
|
||||
held.setKey(false, 38);
|
||||
held.jsevent(1);
|
||||
held.jsevent(0);
|
||||
QCOMPARE(held.plainEvents, QVector<bool>({true, false}));
|
||||
|
||||
ButtonProbe sticky;
|
||||
QString stickyInput("sticky, key 38");
|
||||
QTextStream stickyRead(&stickyInput);
|
||||
QVERIFY(sticky.read(stickyRead));
|
||||
sticky.jsevent(1);
|
||||
sticky.jsevent(0);
|
||||
sticky.jsevent(1);
|
||||
QCOMPARE(sticky.plainEvents, QVector<bool>({true, false}));
|
||||
|
||||
ButtonProbe rapid;
|
||||
QString rapidInput("rapidfire, key 38");
|
||||
QTextStream rapidRead(&rapidInput);
|
||||
QVERIFY(rapid.read(rapidRead));
|
||||
rapid.jsevent(1);
|
||||
rapid.timerTick(FREQ);
|
||||
rapid.timerTick(FREQ + FREQ / 2);
|
||||
rapid.jsevent(0);
|
||||
QCOMPARE(rapid.plainEvents, QVector<bool>({true, false}));
|
||||
|
||||
ButtonProbe mouse;
|
||||
mouse.setKey(true, 1);
|
||||
QVERIFY(mouse.getBinding().isMouse());
|
||||
mouse.jsevent(1);
|
||||
mouse.jsevent(0);
|
||||
QCOMPARE(mouse.plainEvents, QVector<bool>({true, false}));
|
||||
|
||||
Button composite(0);
|
||||
QString compositeInput("sticky, rapidfire, sequence 100 10+11");
|
||||
QTextStream compositeRead(&compositeInput);
|
||||
QVERIFY(composite.read(compositeRead));
|
||||
QString output;
|
||||
QTextStream writer(&output);
|
||||
composite.write(writer);
|
||||
QVERIFY(!output.contains("sticky"));
|
||||
QVERIFY(!output.contains("rapidfire"));
|
||||
|
||||
InputBinding sequence;
|
||||
QVERIFY(InputBinding::parseSequence("100", "10+11/12", &sequence));
|
||||
ButtonProbe cancelling;
|
||||
cancelling.setBinding(sequence);
|
||||
cancelling.jsevent(1);
|
||||
cancelling.release();
|
||||
const QVector<QPair<int, bool>> cancelled = {
|
||||
{10, true}, {11, true}, {11, false}, {10, false}
|
||||
};
|
||||
QCOMPARE(cancelling.compositeEvents, cancelled);
|
||||
}
|
||||
|
||||
void BindingTests::axisCompositeRearmsWithoutRepeating() {
|
||||
AxisProbe axis;
|
||||
QString input("Gradient, +sequence 50 10+11, -sequence 50 12+13");
|
||||
QTextStream reader(&input);
|
||||
QVERIFY(axis.read(reader));
|
||||
axis.jsevent(20000);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(axis.compositeEvents.size(), 4, 150);
|
||||
axis.jsevent(25000);
|
||||
QTest::qWait(80);
|
||||
QCOMPARE(axis.compositeEvents.size(), 4);
|
||||
axis.jsevent(-25000);
|
||||
QTest::qWait(80);
|
||||
QCOMPARE(axis.compositeEvents.size(), 4);
|
||||
axis.jsevent(0);
|
||||
axis.jsevent(20000);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(axis.compositeEvents.size(), 8, 150);
|
||||
|
||||
axis.jsevent(0);
|
||||
axis.jsevent(-20000);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(axis.compositeEvents.size(), 12, 150);
|
||||
QCOMPARE(axis.compositeEvents.at(8), qMakePair(12, true));
|
||||
QCOMPARE(axis.compositeEvents.at(9), qMakePair(13, true));
|
||||
}
|
||||
|
||||
QTEST_MAIN(BindingTests)
|
||||
#include "test_bindings.moc"
|
||||
Reference in New Issue
Block a user