From d79472f7d7e7868c568986645d7ba7e81f0803a8 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 12 Aug 2026 07:39:45 -0400 Subject: [PATCH] Fixed quickbinding bug. --- .gitignore | 4 +- src/bindingdialog.cpp | 248 ++++++++++++++++++++++++++++ src/bindingdialog.h | 81 ++++++++++ src/inputbinding.cpp | 217 +++++++++++++++++++++++++ src/inputbinding.h | 71 ++++++++ src/joypad.cpp | 2 +- src/joypadw.cpp | 6 +- src/joypadw.h | 2 + tests/CMakeLists.txt | 25 +++ tests/test_bindings.cpp | 348 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1001 insertions(+), 3 deletions(-) create mode 100644 src/bindingdialog.cpp create mode 100644 src/bindingdialog.h create mode 100644 src/inputbinding.cpp create mode 100644 src/inputbinding.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_bindings.cpp diff --git a/.gitignore b/.gitignore index ae9e503..b7e7fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,6 @@ Thumbs.db # Distribution files *.tar.gz *.tar.bz2 -*.zip \ No newline at end of file +*.zip +*.tar.xz +*.tar.zst diff --git a/src/bindingdialog.cpp b/src/bindingdialog.cpp new file mode 100644 index 0000000..78f71df --- /dev/null +++ b/src/bindingdialog.cpp @@ -0,0 +1,248 @@ +#include "bindingdialog.h" +#include "keycode.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(event)); + return true; + } + if (event->type() == QEvent::KeyRelease) { + keyReleaseEvent(static_cast(event)); + return true; + } + return false; +} + +bool BindingDialog::getBinding(const QString &controlName, const InputBinding &initial, + InputBinding *binding, bool acceptMouse, QWidget *parent) { + QPointer 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(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(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); + } +} diff --git a/src/bindingdialog.h b/src/bindingdialog.h new file mode 100644 index 0000000..f3309c2 --- /dev/null +++ b/src/bindingdialog.h @@ -0,0 +1,81 @@ +#ifndef THUNDERPAD_BINDINGDIALOG_H +#define THUNDERPAD_BINDINGDIALOG_H + +#include "inputbinding.h" + +#include +#include +#include +#include +#include +#include + +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 currentChord; + QSet 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 diff --git a/src/inputbinding.cpp b/src/inputbinding.cpp new file mode 100644 index 0000000..42be27c --- /dev/null +++ b/src/inputbinding.cpp @@ -0,0 +1,217 @@ +#include "inputbinding.h" + +#include "constant.h" +#include "keycode.h" + +#include +#include + +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 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 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); +} diff --git a/src/inputbinding.h b/src/inputbinding.h new file mode 100644 index 0000000..f7cb240 --- /dev/null +++ b/src/inputbinding.h @@ -0,0 +1,71 @@ +#ifndef THUNDERPAD_INPUTBINDING_H +#define THUNDERPAD_INPUTBINDING_H + +#include +#include +#include +#include + +struct KeyChord { + QVector 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 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 pressedKeys; + int chordIndex; + bool releasing; + bool running; +}; + +#endif diff --git a/src/joypad.cpp b/src/joypad.cpp index 6cfc6d1..a47954e 100644 --- a/src/joypad.cpp +++ b/src/joypad.cpp @@ -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); diff --git a/src/joypadw.cpp b/src/joypadw.cpp index 5af1676..7731683 100644 --- a/src/joypadw.cpp +++ b/src/joypadw.cpp @@ -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); @@ -238,4 +242,4 @@ void JoyPadWidget::onButtonSelectionChanged() { } else { buttonStatusLabel->setText("Select a button to configure"); } -} \ No newline at end of file +} diff --git a/src/joypadw.h b/src/joypadw.h index 7c6766e..ee96719 100644 --- a/src/joypadw.h +++ b/src/joypadw.h @@ -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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..364a3c7 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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") diff --git a/tests/test_bindings.cpp b/tests/test_bindings.cpp new file mode 100644 index 0000000..6e12e62 --- /dev/null +++ b/tests/test_bindings.cpp @@ -0,0 +1,348 @@ +#include "axis.h" +#include "bindingdialog.h" +#include "button.h" +#include "inputbinding.h" + +#include +#include +#include +#include +#include +#include + +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(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(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(); + QVERIFY(target); + QKeyEvent pressEvent(QEvent::KeyPress, qtKey, Qt::NoModifier, + static_cast(scanCode), 0, 0); + QApplication::sendEvent(target, &pressEvent); + QKeyEvent releaseEvent(QEvent::KeyRelease, qtKey, Qt::NoModifier, + static_cast(scanCode), 0, 0); + QApplication::sendEvent(target, &releaseEvent); + } + bool cancelAvailable() const { + for (QPushButton *button : findChildren()) { + if (button->text().contains("Cancel")) return button->isVisibleTo(this); + } + return false; + } +}; + +class ButtonProbe : public Button { +public: + ButtonProbe() : Button(0) {} + QVector plainEvents; + QVector> 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> 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("delay"); + QTest::addColumn("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({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{{64, 28}}})); + + RecorderProbe steps; + steps.tap(Qt::Key_A, 38); + steps.tap(Qt::Key_B, 56); + steps.finish(); + QCOMPARE(steps.binding().chords, QVector({KeyChord{{38}}, KeyChord{{56}}})); + + RecorderProbe tab; + tab.dispatchTap(Qt::Key_Tab, 23); + tab.finish(); + QCOMPARE(tab.binding().chords, QVector({KeyChord{{23}}})); + QVERIFY(tab.cancelAvailable()); +} + +void BindingTests::recorderModifiersRepeatLimitAndFinish() { + RecorderProbe modifier; + modifier.tap(Qt::Key_Shift, 50); + modifier.finish(); + QCOMPARE(modifier.binding().chords, QVector({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{{38}}})); + + RecorderProbe separateCtrlEnter; + separateCtrlEnter.tap(Qt::Key_Control, 37); + separateCtrlEnter.tap(Qt::Key_Return, 36); + separateCtrlEnter.finish(); + QCOMPARE(separateCtrlEnter.binding().chords, + QVector({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> events; + QVector 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> 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> 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({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({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({true, false})); + + ButtonProbe mouse; + mouse.setKey(true, 1); + QVERIFY(mouse.getBinding().isMouse()); + mouse.jsevent(1); + mouse.jsevent(0); + QCOMPARE(mouse.plainEvents, QVector({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> 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"