diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a5f10c6..2af116d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ set(libinputactions_SRCS libinputactions/actions/CommandAction.cpp libinputactions/actions/ContextMenuAction.cpp libinputactions/actions/CustomAction.cpp + libinputactions/actions/DirectionalAction.cpp libinputactions/actions/InputAction.cpp libinputactions/actions/PlasmaGlobalShortcutAction.cpp libinputactions/actions/ReplaceTextAction.cpp diff --git a/src/libinputactions/actions/Action.h b/src/libinputactions/actions/Action.h index eb7c0be..7be423c 100644 --- a/src/libinputactions/actions/Action.h +++ b/src/libinputactions/actions/Action.h @@ -21,6 +21,7 @@ #include #include #include +#include namespace InputActions { @@ -40,6 +41,10 @@ struct ActionExecutionArguments * For the move_by_delta mouse action. */ PointF motionPointDelta; + /** + * Average angle of swipe triggers in degrees (right = 0, up = 90, left = 180, down = 270). For the directional action. + */ + std::optional motionAngle; } inputActionArgs; }; diff --git a/src/libinputactions/actions/DirectionalAction.cpp b/src/libinputactions/actions/DirectionalAction.cpp new file mode 100644 index 0000000..6942654 --- /dev/null +++ b/src/libinputactions/actions/DirectionalAction.cpp @@ -0,0 +1,167 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2026 Owen271 + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "DirectionalAction.h" +#include "ActionExecutor.h" +#include +#include +#include +#include +#include +#include +#include + +namespace InputActions +{ + +/** + * Limits the actions executed for a single large motion event. + */ +static const int MAX_ACTIONS_PER_UPDATE = 8; + +/** + * @return Index into Bindings for the dominant axis. + */ +static size_t direction(qreal x, qreal y) +{ + if (std::abs(x) >= std::abs(y)) { + return x < 0 ? 0 : 1; + } + return y < 0 ? 2 : 3; +} + +/** + * Motion against the accumulated direction restarts accumulation. + */ +static void accumulate(qreal &total, qreal delta) +{ + total = delta != 0 && total * delta < 0 ? delta : total + delta; +} + +DirectionalAction::DirectionalAction(qreal distance, Bindings actions, Bindings initial) + : m_distance(distance) + , m_actions(std::move(actions)) + , m_initial(std::move(initial)) +{ +} + +void DirectionalAction::reset() +{ + Action::reset(); + m_firstUpdate = true; + m_x = m_y = 0; + for (const auto *bindings : {&m_actions, &m_initial}) { + for (const auto &action : *bindings) { + if (action) { + action->reset(); + } + } + } +} + +void DirectionalAction::doExecute(const ActionExecutionArguments &args) +{ + if (QThread::currentThread() == QThreadHelpers::mainThread()) { + move(args); + return; + } + + // The repeat condition must be checked when the actions are executed, otherwise input could reach an application after the controlled UI has closed. + // Not blocking, as the main thread may be waiting for this thread, for example while reloading the configuration. + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [this, alive = std::weak_ptr(m_alive), args] { + // The action may have been destroyed while the motion was queued. + if (alive.lock()) { + move(args); + } + }, + Qt::QueuedConnection); +} + +void DirectionalAction::move(const ActionExecutionArguments &args) +{ + const auto delta = args.inputActionArgs.motionPointDelta; + + // The initial action waits for motion. Only swipe triggers provide two-axis motion. + if (m_firstUpdate && delta.x() == 0 && delta.y() == 0) { + return; + } + + const auto repeatAllowed = !m_repeatCondition || m_repeatCondition->satisfied(); + if (m_firstUpdate) { + m_firstUpdate = false; + const auto hasInitial = std::ranges::any_of(m_initial, [](const auto &action) { + return action != nullptr; + }); + // If the controlled UI is already open, navigate it instead of opening it again. + if (hasInitial && !(m_repeatCondition && repeatAllowed)) { + auto initialDirection = direction(delta.x(), delta.y()); + if (args.inputActionArgs.motionAngle) { + const auto angle = *args.inputActionArgs.motionAngle * std::numbers::pi / 180; + initialDirection = direction(std::cos(angle), -std::sin(angle)); + } + executeAction(m_initial[initialDirection].get(), args); + // The motion that executed the initial action does not count towards the next one. + return; + } + } + + if (!repeatAllowed) { + m_x = m_y = 0; + return; + } + + accumulate(m_x, delta.x()); + accumulate(m_y, delta.y()); + for (auto executed = 0; executed < MAX_ACTIONS_PER_UPDATE && std::max(std::abs(m_x), std::abs(m_y)) >= m_distance; executed++) { + // The previous action may have closed the controlled UI. + if (executed && m_repeatCondition && !m_repeatCondition->satisfied()) { + m_x = m_y = 0; + return; + } + + const auto selected = direction(m_x, m_y); + executeAction(m_actions[selected].get(), args); + if (selected <= 1) { + m_x += m_x < 0 ? m_distance : -m_distance; + m_y = 0; + } else { + m_y += m_y < 0 ? m_distance : -m_distance; + m_x = 0; + } + } +} + +void DirectionalAction::executeAction(Action *action, const ActionExecutionArguments &args) +{ + if (!action || !action->canExecute()) { + return; + } + g_actionExecutor->execute(*action, + { + // Synchronous actions are executed immediately after the repeat condition has been checked. Asynchronous actions would + // block the main thread. + .thread = action->async() ? ActionThread::Auto : ActionThread::Current, + .actionArgs = { + .inputActionArgs = args.inputActionArgs, + }, + }); +} + +} \ No newline at end of file diff --git a/src/libinputactions/actions/DirectionalAction.h b/src/libinputactions/actions/DirectionalAction.h new file mode 100644 index 0000000..9712541 --- /dev/null +++ b/src/libinputactions/actions/DirectionalAction.h @@ -0,0 +1,86 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2026 Owen271 + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#pragma once + +#include "Action.h" +#include + +namespace InputActions +{ + +class Condition; + +/** + * Converts two-axis motion into discrete actions. Every time the motion along the dominant axis reaches the distance, the action for that direction is + * executed. + */ +class DirectionalAction : public Action +{ +public: + /** + * Actions ordered left, right, up, down. Each may be nullptr. + */ + using Bindings = std::array, 4>; + + /** + * @param distance Motion required per action. Must be greater than zero. + * @param initial Executed on the first update with motion instead of waiting for the distance, using the swipe angle if available. + */ + DirectionalAction(qreal distance, Bindings actions, Bindings initial = {}); + + qreal distance() const { return m_distance; } + const Bindings &actions() const { return m_actions; } + const Bindings &initial() const { return m_initial; } + + /** + * Checked before executing each action. While not satisfied, motion is discarded. If satisfied on the first update with motion, the initial action is not + * executed. + * May be nullptr. + */ + const std::shared_ptr &repeatCondition() const { return m_repeatCondition; } + void setRepeatCondition(std::shared_ptr value) { m_repeatCondition = std::move(value); } + + void reset() override; + +protected: + void doExecute(const ActionExecutionArguments &args) override; + +private: + void move(const ActionExecutionArguments &args); + /** + * Must be called from the main thread. + */ + static void executeAction(Action *action, const ActionExecutionArguments &args); + + qreal m_distance; + Bindings m_actions; + Bindings m_initial; + std::shared_ptr m_repeatCondition; + + /** + * Expires when the action is destroyed, for motion queued on the main thread. + */ + std::shared_ptr m_alive = std::make_shared(); + + bool m_firstUpdate = true; + qreal m_x{}; + qreal m_y{}; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/actions/TriggerAction.cpp b/src/libinputactions/actions/TriggerAction.cpp index 66d471b..dd5f2a5 100644 --- a/src/libinputactions/actions/TriggerAction.cpp +++ b/src/libinputactions/actions/TriggerAction.cpp @@ -20,6 +20,7 @@ #include "ActionExecutor.h" #include #include +#include namespace InputActions { @@ -54,6 +55,9 @@ void TriggerAction::triggerUpdated(const TriggerUpdateEvent &event) const auto &pointDelta = motionEvent->pointDelta(); m_currentMotionPointDelta = m_accelerated ? pointDelta.accelerated() : pointDelta.unaccelerated(); } + if (const auto *swipeEvent = dynamic_cast(&event)) { + m_currentMotionAngle = swipeEvent->averageAngle(); + } update(event.delta()); } @@ -88,6 +92,7 @@ void TriggerAction::tryExecute(uint32_t executions) .executions = executions, .inputActionArgs = { .motionPointDelta = m_currentMotionPointDelta, + .motionAngle = m_currentMotionAngle, }, }}); } diff --git a/src/libinputactions/actions/TriggerAction.h b/src/libinputactions/actions/TriggerAction.h index 377d2aa..10261cf 100644 --- a/src/libinputactions/actions/TriggerAction.h +++ b/src/libinputactions/actions/TriggerAction.h @@ -187,6 +187,7 @@ class TriggerAction qreal m_absoluteAccumulatedDelta{}; PointF m_currentMotionPointDelta; + std::optional m_currentMotionAngle; friend class TestTrigger; }; diff --git a/src/libinputactions/config/parsers/core.cpp b/src/libinputactions/config/parsers/core.cpp index 04d8a01..c5e90d9 100644 --- a/src/libinputactions/config/parsers/core.cpp +++ b/src/libinputactions/config/parsers/core.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -162,6 +163,31 @@ void NodeParser>::parse(const Node *node, std::unique_pt } else if (const auto *contextMenuNode = node->at("context_menu")) { g_configLoader->requestOverlay(contextMenuNode); result = std::make_unique(contextMenuNode->as>>()); + } else if (const auto *directionalNode = node->mapAt("directional")) { + qreal distance = 75; + if (const auto *distanceNode = directionalNode->at("distance")) { + distance = distanceNode->as(); + if (distance <= 0) { + throw InvalidValueConfigException(distanceNode, "Distance must be greater than zero."); + } + } + const auto parseBindings = [](const Node *bindings) { + DirectionalAction::Bindings result; + if (bindings) { + const std::array names = {"left", "right", "up", "down"}; + for (size_t i = 0; i < names.size(); ++i) { + if (const auto *binding = bindings->at(names[i])) { + result[i] = binding->as>(); + } + } + } + return result; + }; + auto action = std::make_unique(distance, + parseBindings(directionalNode->mapAt("actions", true)), + parseBindings(directionalNode->mapAt("initial"))); + loadSetter(action, &DirectionalAction::setRepeatCondition, directionalNode->at("repeat_conditions")); + result = std::move(action); } else if (const auto *functionNode = node->at("function")) { result = std::make_unique(parseFunction(functionNode), functionNode->shared_from_this()); } else if (const auto *inputNode = node->at("input")) { @@ -178,13 +204,25 @@ void NodeParser>::parse(const Node *node, std::unique_pt } else if (const auto *oneNode = node->at("one")) { auto action = std::make_unique(ActionGroupExecutionMode::First); loadSetter(action.get(), &ActionGroup::setActions, oneNode); + // Directional actions must be the action of a trigger action, which checks that it executes them on update. + if (std::ranges::any_of(action->actions(), [](const auto &child) { + return dynamic_cast(child); + })) { + throw InvalidValueContextConfigException(oneNode, "Directional actions cannot be in groups."); + } result = std::move(action); } else { throw InvalidValueConfigException(node, "Action is missing a required property that determines its type."); } loadSetter(result, &Action::setCondition, node->at("conditions")); - loadSetter(result, &Action::setExecutionLimit, node->at("limit")); + if (const auto *limitNode = node->at("limit")) { + // An ignored update would count towards the limit. + if (dynamic_cast(result.get())) { + throw InvalidValueContextConfigException(limitNode, "Limits cannot be set on directional actions, only on the actions of each direction."); + } + loadSetter(result, &Action::setExecutionLimit, limitNode); + } loadSetter(result, &Action::setId, node->at("id")); } @@ -650,10 +688,20 @@ void NodeParser>::parse(const Node *node, std::un loadSetter(result, &TriggerAction::setConflicting, node->at("conflicting")); loadSetter(result, &TriggerAction::setOn, node->at("on")); + // Directional actions process the motion of each update, and use their own distance instead of an interval. + const auto directional = dynamic_cast(result->action()) != nullptr; + if (directional && result->on() != On::Update) { + const auto *onNode = node->at("on"); + throw InvalidValueContextConfigException(onNode ? onNode : node, "Directional actions can only be set on update actions."); + } + if (const auto *intervalNode = node->at("interval")) { if (result->on() != On::Update && result->on() != On::Tick) { throw InvalidValueContextConfigException(intervalNode, "Intervals can only be set on update and tick actions."); } + if (directional) { + throw InvalidValueContextConfigException(intervalNode, "Intervals cannot be set on directional actions, which use their own distance."); + } loadSetter(result, &TriggerAction::setInterval, intervalNode); } diff --git a/src/libinputactions/triggers/Trigger.cpp b/src/libinputactions/triggers/Trigger.cpp index 17c4f3e..056b218 100644 --- a/src/libinputactions/triggers/Trigger.cpp +++ b/src/libinputactions/triggers/Trigger.cpp @@ -18,6 +18,8 @@ #include "Trigger.h" #include "core/TriggerCore.h" +#include +#include #include #include #include @@ -157,9 +159,28 @@ void Trigger::stopResumeTimeoutTimer() m_resumeTimeoutTimer.stop(); } +/** + * @return Whether the action is a directional action with an input action for any direction. + */ +static bool directionalExecutesInput(const Action *action) +{ + const auto *directionalAction = dynamic_cast(action); + if (!directionalAction) { + return false; + } + for (const auto *bindings : {&directionalAction->actions(), &directionalAction->initial()}) { + if (std::ranges::any_of(*bindings, [](const auto &binding) { + return dynamic_cast(binding.get()); + })) { + return true; + } + } + return false; +} + void Trigger::actionAdded(TriggerAction *action) { - if (dynamic_cast(action->action())) { + if (dynamic_cast(action->action()) || directionalExecutesInput(action->action())) { if (!m_clearModifiers) { m_clearModifiers = true; } diff --git a/tests/libinputactions/CMakeLists.txt b/tests/libinputactions/CMakeLists.txt index a2f963d..d2f1710 100644 --- a/tests/libinputactions/CMakeLists.txt +++ b/tests/libinputactions/CMakeLists.txt @@ -42,6 +42,8 @@ libinputactions_add_test(TestConditionGroup SOURCES conditions/TestConditionGrou libinputactions_add_test(TestConditionGroupNodeParser SOURCES config/parsers/conditions/TestConditionGroupNodeParser.cpp) libinputactions_add_test(TestConditionNodeParser SOURCES config/parsers/conditions/TestConditionNodeParser.cpp) libinputactions_add_test(TestDeviceRuleNodeParser SOURCES config/parsers/TestDeviceRuleNodeParser.cpp) +libinputactions_add_test(TestDirectionalAction SOURCES actions/TestDirectionalAction.cpp) +libinputactions_add_test(TestDirectionalActionNodeParser SOURCES config/parsers/actions/TestDirectionalActionNodeParser.cpp) libinputactions_add_test(TestDirectionalMotionTriggerCore SOURCES triggers/core/TestDirectionalMotionTriggerCore.cpp) libinputactions_add_test(TestEnumNodeParser SOURCES config/parsers/TestEnumNodeParser.cpp) libinputactions_add_test(TestFlagsNodeParser SOURCES config/parsers/TestFlagsNodeParser.cpp) diff --git a/tests/libinputactions/actions/TestDirectionalAction.cpp b/tests/libinputactions/actions/TestDirectionalAction.cpp new file mode 100644 index 0000000..fcfc57e --- /dev/null +++ b/tests/libinputactions/actions/TestDirectionalAction.cpp @@ -0,0 +1,402 @@ +#include "Test.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include +#include + +namespace InputActions +{ + +/** + * Keeps the shared action thread busy until destroyed. + */ +class BusyActionThread +{ +public: + BusyActionThread() + : m_action( + [this](const auto &) { + m_started.release(); + m_release.acquire(); + }, + true) + { + g_actionExecutor->execute(m_action); + m_started.acquire(); + } + + ~BusyActionThread() + { + release(); + g_actionExecutor->waitForDone(); + } + + void release() + { + if (!m_released) { + m_released = true; + m_release.release(); + } + } + +private: + QSemaphore m_started; + QSemaphore m_release; + bool m_released{}; + CustomAction m_action; +}; + +class TestDirectionalAction : public Test +{ + Q_OBJECT + +private slots: + void init() { m_log.clear(); } + + void execute_distanceReachedOverMultipleUpdates__executesAction() + { + DirectionalAction action(75, arrows()); + + move(action, 74, 0); + QCOMPARE(m_log, QStringList()); + + move(action, 1, 0); + QCOMPARE(m_log, QStringList({"right"})); + } + + void execute_eachDirection__executesActionForDirection_data() + { + QTest::addColumn("x"); + QTest::addColumn("y"); + QTest::addColumn("name"); + + QTest::newRow("left") << -75.0 << 0.0 << "left"; + QTest::newRow("right") << 75.0 << 0.0 << "right"; + QTest::newRow("up") << 0.0 << -75.0 << "up"; + QTest::newRow("down") << 0.0 << 75.0 << "down"; + } + + void execute_eachDirection__executesActionForDirection() + { + QFETCH(qreal, x); + QFETCH(qreal, y); + QFETCH(QString, name); + DirectionalAction action(75, arrows()); + + move(action, x, y); + + QCOMPARE(m_log, QStringList({name})); + } + + void execute_directionReversed__restartsAccumulation() + { + DirectionalAction action(75, arrows()); + + move(action, 74, 0); + move(action, -75, 0); + + QCOMPARE(m_log, QStringList({"left"})); + } + + void execute_largeMotion__executesAtMostEightActions() + { + DirectionalAction action(75, arrows()); + + move(action, 75 * 20, 0); + + QCOMPARE(m_log.size(), 8); + } + + void execute_axisChanged__resetsOtherAxis() + { + DirectionalAction action(75, arrows()); + + move(action, 74, 0); + move(action, 0, 75); + move(action, 1, 0); + QCOMPARE(m_log, QStringList({"down"})); + + move(action, 0, 74); + move(action, 75, 0); + move(action, 0, 1); + QCOMPARE(m_log, QStringList({"down", "right"})); + } + + void execute_actionConditionNotSatisfied__skipsActionAndConsumesMotion() + { + bool satisfied = false; + auto actions = arrows(); + actions[1]->setCondition(referenceCondition(satisfied)); + DirectionalAction action(75, std::move(actions)); + + move(action, 75, 0); + QCOMPARE(m_log, QStringList()); + + satisfied = true; + move(action, 74, 0); + QCOMPARE(m_log, QStringList()); + + move(action, 1, 0); + QCOMPARE(m_log, QStringList({"right"})); + } + + void execute_fromOtherThreadWhileMainThreadWaits__executesOnMainThreadWithoutDeadlock() + { + // The main thread waits for action threads while reloading the configuration. + QThread *thread{}; + DirectionalAction::Bindings actions; + actions[1] = std::make_unique([&thread](const auto &) { + thread = QThread::currentThread(); + }); + DirectionalAction action(75, std::move(actions)); + QThreadPool pool; + + pool.start([&action] { + move(action, 75, 0); + }); + const auto finished = pool.waitForDone(1000); + while (!pool.waitForDone(10)) { + QCoreApplication::processEvents(); + } + QVERIFY(finished); + + QTRY_VERIFY(thread); + QCOMPARE(thread, QThreadHelpers::mainThread()); + } + + void execute_fromOtherThread_destroyedBeforeMainThread__ignoresMotion() + { + auto action = std::make_unique(75, arrows()); + auto *raw = action.get(); + QThreadPool pool; + + pool.start([raw] { + move(*raw, 75, 0); + }); + pool.waitForDone(); + action.reset(); + QCoreApplication::sendPostedEvents(); + + QCOMPARE(m_log, QStringList()); + } + + void execute_actionThreadBusy__executesSynchronousActionImmediately() + { + DirectionalAction action(75, arrows()); + QStringList log; + { + BusyActionThread busy; + move(action, 75, 0); + log = m_log; + } + + QCOMPARE(log, QStringList({"right"})); + } + + void execute_asynchronousAction__executesOnActionThread() + { + QThread *thread{}; + DirectionalAction::Bindings actions; + actions[1] = std::make_unique( + [&thread](const auto &) { + thread = QThread::currentThread(); + }, + true); + DirectionalAction action(75, std::move(actions)); + + move(action, 75, 0); + g_actionExecutor->waitForDone(); + + QVERIFY(thread); + QVERIFY(thread != QThreadHelpers::mainThread()); + } + + void execute_initial__executesInitialImmediatelyAndDoesNotCountMotion() + { + DirectionalAction action(75, arrows(), tabs()); + + move(action, 1, 0); + QCOMPARE(m_log, QStringList({"tab"})); + + move(action, 74, 0); + QCOMPARE(m_log, QStringList({"tab"})); + + move(action, 1, 0); + QCOMPARE(m_log, QStringList({"tab", "right"})); + } + + void execute_initial_motionAngle__usesDominantAxisOfAngle_data() + { + QTest::addColumn("angle"); + QTest::addColumn("name"); + + QTest::newRow("right") << 0.0 << "initial right"; + QTest::newRow("up") << 90.0 << "initial up"; + QTest::newRow("left") << 180.0 << "initial left"; + QTest::newRow("down") << 270.0 << "initial down"; + QTest::newRow("mostly up") << 60.0 << "initial up"; + QTest::newRow("mostly right") << 330.0 << "initial right"; + } + + void execute_initial_motionAngle__usesDominantAxisOfAngle() + { + QFETCH(qreal, angle); + QFETCH(QString, name); + DirectionalAction action(75, arrows(), bindings({"initial left", "initial right", "initial up", "initial down"})); + + move(action, 1, 0, angle); + + QCOMPARE(m_log, QStringList({name})); + } + + void execute_initial_repeatConditionSatisfied__skipsInitial() + { + bool satisfied = true; + DirectionalAction action(75, arrows(), tabs()); + action.setRepeatCondition(referenceCondition(satisfied)); + + move(action, 75, 0); + + QCOMPARE(m_log, QStringList({"right"})); + } + + void execute_initial_repeatConditionNotSatisfied__executesOnlyInitial() + { + bool satisfied = false; + DirectionalAction action(75, arrows(), tabs()); + action.setRepeatCondition(referenceCondition(satisfied)); + + move(action, 1, 0); + move(action, 150, 0); + + QCOMPARE(m_log, QStringList({"tab"})); + } + + void execute_actionUnsatisfiesRepeatCondition__discardsRemainingMotion() + { + bool satisfied = true; + DirectionalAction::Bindings actions; + actions[1] = std::make_unique([this, &satisfied](const auto &) { + m_log.push_back("right"); + satisfied = false; + }); + DirectionalAction action(75, std::move(actions)); + action.setRepeatCondition(referenceCondition(satisfied)); + + move(action, 150, 0); + QCOMPARE(m_log, QStringList({"right"})); + + satisfied = true; + move(action, 74, 0); + QCOMPARE(m_log, QStringList({"right"})); + } + + void execute_repeatConditionNotSatisfied__discardsMotion() + { + bool satisfied = false; + DirectionalAction action(75, arrows()); + action.setRepeatCondition(referenceCondition(satisfied)); + + move(action, 74, 0); + satisfied = true; + move(action, 1, 0); + QCOMPARE(m_log, QStringList()); + + move(action, 74, 0); + QCOMPARE(m_log, QStringList({"right"})); + } + + void execute_noMotion__isIgnored() + { + DirectionalAction action(75, arrows(), tabs()); + + move(action, 0, 0); + move(action, 0, 0, 180); + QCOMPARE(m_log, QStringList()); + + move(action, -1, 0); + QCOMPARE(m_log, QStringList({"shift+tab"})); + } + + void reset_actionLimitReached__executesActionAgain() + { + auto actions = arrows(); + actions[1]->setExecutionLimit(1); + DirectionalAction action(75, std::move(actions)); + + move(action, 150, 0); + QCOMPARE(m_log, QStringList({"right"})); + + action.reset(); + move(action, 75, 0); + QCOMPARE(m_log, QStringList({"right", "right"})); + } + + void reset__executesInitialAgain() + { + DirectionalAction action(75, arrows(), tabs()); + + move(action, 1, 0); + action.reset(); + move(action, 1, 0); + + QCOMPARE(m_log, QStringList({"tab", "tab"})); + } + + void reset_partialMotion__discardsAccumulatedMotion() + { + DirectionalAction action(75, arrows()); + + move(action, 74, 0); + action.reset(); + move(action, 1, 0); + + QCOMPARE(m_log, QStringList()); + } + +private: + std::unique_ptr record(const QString &name) + { + return std::make_unique([this, name](const auto &) { + m_log.push_back(name); + }); + } + + /** + * @param names Ordered left, right, up, down. An empty name leaves the direction without an action. + */ + DirectionalAction::Bindings bindings(const std::array &names) + { + DirectionalAction::Bindings result; + for (size_t i = 0; i < names.size(); ++i) { + if (!names[i].isEmpty()) { + result[i] = record(names[i]); + } + } + return result; + } + + DirectionalAction::Bindings arrows() { return bindings({"left", "right", "up", "down"}); } + DirectionalAction::Bindings tabs() { return bindings({"shift+tab", "tab", "", ""}); } + + static ActionExecutionArguments motion(qreal x, qreal y, std::optional angle = {}) + { + ActionExecutionArguments args; + args.inputActionArgs.motionPointDelta = PointF(x, y); + args.inputActionArgs.motionAngle = angle; + return args; + } + + static void move(DirectionalAction &action, qreal x, qreal y, std::optional angle = {}) { action.execute(motion(x, y, angle)); } + + QStringList m_log; +}; + +} + +QTEST_MAIN(InputActions::TestDirectionalAction) +#include "TestDirectionalAction.moc" \ No newline at end of file diff --git a/tests/libinputactions/actions/TestTriggerAction.cpp b/tests/libinputactions/actions/TestTriggerAction.cpp index 4019a12..d78e954 100644 --- a/tests/libinputactions/actions/TestTriggerAction.cpp +++ b/tests/libinputactions/actions/TestTriggerAction.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace InputActions { @@ -109,6 +110,23 @@ private slots: action.tryExecute(); QCOMPARE(assertAction->executions(), 1); } + + void tryExecute_swipe__passesMotionAngleToAction() + { + auto *assertAction = new CustomAction([](const auto &args) { + QCOMPARE(args.inputActionArgs.motionAngle.value_or(-1), 180); + }); + + TriggerAction action{std::unique_ptr(assertAction)}; + + action.triggerStarted(); + SwipeTriggerUpdateEvent event; + event.setAverageAngle(180); + action.triggerUpdated(event); + + action.tryExecute(); + QCOMPARE(assertAction->executions(), 1); + } }; } diff --git a/tests/libinputactions/config/parsers/actions/TestDirectionalActionNodeParser.cpp b/tests/libinputactions/config/parsers/actions/TestDirectionalActionNodeParser.cpp new file mode 100644 index 0000000..3c4d066 --- /dev/null +++ b/tests/libinputactions/config/parsers/actions/TestDirectionalActionNodeParser.cpp @@ -0,0 +1,148 @@ +#include "Test.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace InputActions +{ + +class TestDirectionalActionNodeParser : public Test +{ + Q_OBJECT + +private slots: + void init() + { + g_variableRegistry = std::make_shared(); + g_variableRegistry->registerStored("a"); + } + + void actions__parsesNodeCorrectly() + { + const auto node = Node::create(R"( + directional: + actions: + left: + input: + - keyboard: [ left ] + right: + plasma_shortcut: kwin,Switch One Desktop to the Right + )"); + const auto action = node->as>(); + + const auto *directionalAction = dynamic_cast(action.get()); + QVERIFY(directionalAction); + QCOMPARE(directionalAction->distance(), 75); + QVERIFY(!directionalAction->repeatCondition()); + + const auto &actions = directionalAction->actions(); + QVERIFY(dynamic_cast(actions[0].get())); + QVERIFY(dynamic_cast(actions[1].get())); + QVERIFY(!actions[2]); + QVERIFY(!actions[3]); + + for (const auto &initial : directionalAction->initial()) { + QVERIFY(!initial); + } + } + + void allProperties__parsesNodeCorrectly() + { + const auto node = Node::create(R"( + directional: + distance: 50 + initial: + up: + command: true + actions: + down: + input: + - keyboard: [ down ] + conditions: $a + repeat_conditions: $a + )"); + const auto action = node->as>(); + + const auto *directionalAction = dynamic_cast(action.get()); + QVERIFY(directionalAction); + QCOMPARE(directionalAction->distance(), 50); + QVERIFY(directionalAction->repeatCondition()); + + const auto *down = directionalAction->actions()[3].get(); + QVERIFY(dynamic_cast(down)); + QVERIFY(down->condition()); + QVERIFY(dynamic_cast(directionalAction->initial()[2].get())); + } + + void noActions__throwsMissingRequiredPropertyConfigException() + { + const auto node = Node::create("directional: { distance: 50 }"); + + QVERIFY_THROWS_EXCEPTION(MissingRequiredPropertyConfigException, node->as>()); + } + + void limit__throwsInvalidValueContextConfigException() + { + const auto node = Node::create("{ limit: 1, directional: { actions: { left: { command: true } } } }"); + + INPUTACTIONS_VERIFY_THROWS_CONFIG_EXCEPTION(node->as>(), InvalidValueContextConfigException, 0, 9); + } + + void inGroup__throwsInvalidValueContextConfigException() + { + const auto node = Node::create("{ on: update, one: [ { directional: { actions: { left: { command: true } } } } ] }"); + + INPUTACTIONS_VERIFY_THROWS_CONFIG_EXCEPTION(node->as>(), InvalidValueContextConfigException, 0, 19); + } + + void triggerAction_interval__throwsInvalidValueContextConfigException() + { + const auto node = Node::create("{ on: update, interval: 10, directional: { actions: { left: { command: true } } } }"); + + INPUTACTIONS_VERIFY_THROWS_CONFIG_EXCEPTION(node->as>(), InvalidValueContextConfigException, 0, 24); + } + + void triggerAction_notUpdate__throwsInvalidValueContextConfigException_data() + { + QTest::addColumn("raw"); + QTest::addColumn("column"); + + QTest::newRow("end") << "{ on: end, directional: { actions: { left: { command: true } } } }" << 6; + QTest::newRow("default") << "{ directional: { actions: { left: { command: true } } } }" << 0; + } + + void triggerAction_notUpdate__throwsInvalidValueContextConfigException() + { + QFETCH(QString, raw); + QFETCH(int, column); + const auto node = Node::create(raw); + + INPUTACTIONS_VERIFY_THROWS_CONFIG_EXCEPTION(node->as>(), InvalidValueContextConfigException, 0, column); + } + + void invalidDistance__throwsInvalidValueConfigException_data() + { + QTest::addColumn("distance"); + + QTest::newRow("zero") << "0"; + QTest::newRow("negative") << "-1"; + } + + void invalidDistance__throwsInvalidValueConfigException() + { + QFETCH(QString, distance); + const auto node = Node::create(QString("directional: { distance: %1, actions: { left: { command: true } } }").arg(distance)); + + INPUTACTIONS_VERIFY_THROWS_CONFIG_EXCEPTION(node->as>(), InvalidValueConfigException, 0, 25); + } +}; + +} + +QTEST_MAIN(InputActions::TestDirectionalActionNodeParser) +#include "TestDirectionalActionNodeParser.moc" \ No newline at end of file diff --git a/tests/libinputactions/triggers/TestTrigger.cpp b/tests/libinputactions/triggers/TestTrigger.cpp index 19460eb..75f8ec2 100644 --- a/tests/libinputactions/triggers/TestTrigger.cpp +++ b/tests/libinputactions/triggers/TestTrigger.cpp @@ -1,6 +1,9 @@ #include "Test.h" #include "mocks/MockTrigger.h" #include +#include +#include +#include #include namespace InputActions @@ -48,6 +51,39 @@ private slots: } QCOMPARE(actionRaw->action()->executions(), actionExecuted); } + + void addAction_directionalInput__clearsModifiers_data() + { + QTest::addColumn("initial"); + + QTest::newRow("actions") << false; + QTest::newRow("initial") << true; + } + + void addAction_directionalInput__clearsModifiers() + { + QFETCH(bool, initial); + DirectionalAction::Bindings actions; + DirectionalAction::Bindings initialActions; + auto &bindings = initial ? initialActions : actions; + bindings[0] = std::make_unique(std::vector{}); + + MockTrigger trigger; + trigger.addAction(std::make_unique(std::make_unique(75, std::move(actions), std::move(initialActions)))); + + QCOMPARE(trigger.m_clearModifiers, std::optional(true)); + } + + void addAction_directionalWithoutInput__doesNotClearModifiers() + { + DirectionalAction::Bindings actions; + actions[0] = std::make_unique([](const auto &) {}); + + MockTrigger trigger; + trigger.addAction(std::make_unique(std::make_unique(75, std::move(actions)))); + + QVERIFY(!trigger.m_clearModifiers); + } }; }