From 547e8af7cdad92d5b06a0a83d89a0c601096e6a9 Mon Sep 17 00:00:00 2001 From: taj-ny <79316397+taj-ny@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:21:32 +0200 Subject: [PATCH] add context menu action --- src/CMakeLists.txt | 2 + .../actions/ContextMenuAction.cpp | 133 ++++++++++++++++++ .../actions/ContextMenuAction.h | 123 ++++++++++++++++ src/libinputactions/config/ConfigIssue.cpp | 3 +- src/libinputactions/config/ConfigLoader.cpp | 22 ++- src/libinputactions/config/ConfigLoader.h | 8 ++ src/libinputactions/config/parsers/core.cpp | 40 +++++- .../helpers/QDBusInterface.cpp | 35 +++++ src/libinputactions/helpers/QDBusInterface.h | 69 +++++++++ .../interfaces/OverlayManager.cpp | 47 +++++++ .../interfaces/OverlayManager.h | 11 ++ 11 files changed, 480 insertions(+), 13 deletions(-) create mode 100644 src/libinputactions/actions/ContextMenuAction.cpp create mode 100644 src/libinputactions/actions/ContextMenuAction.h create mode 100644 src/libinputactions/helpers/QDBusInterface.cpp create mode 100644 src/libinputactions/helpers/QDBusInterface.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b5b260c..7497152 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ set(libinputactions_SRCS libinputactions/actions/ActionExecutor.cpp libinputactions/actions/ActionGroup.cpp libinputactions/actions/CommandAction.cpp + libinputactions/actions/ContextMenuAction.cpp libinputactions/actions/CustomAction.cpp libinputactions/actions/InputAction.cpp libinputactions/actions/PlasmaGlobalShortcutAction.cpp @@ -49,6 +50,7 @@ set(libinputactions_SRCS libinputactions/handlers/TriggerHandler.cpp libinputactions/helpers/Math.cpp libinputactions/helpers/QDBusConnection.cpp + libinputactions/helpers/QDBusInterface.cpp libinputactions/helpers/QProcess.cpp libinputactions/helpers/QString.cpp libinputactions/helpers/QThread.cpp diff --git a/src/libinputactions/actions/ContextMenuAction.cpp b/src/libinputactions/actions/ContextMenuAction.cpp new file mode 100644 index 0000000..3b8c192 --- /dev/null +++ b/src/libinputactions/actions/ContextMenuAction.cpp @@ -0,0 +1,133 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + 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 "ContextMenuAction.h" +#include +#include +#include +#include +#include +#include + +namespace InputActions +{ + +ActionMenuItem::ActionMenuItem(std::vector> actions) + : m_actions(std::move(actions)) + , m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)) +{ +} + +QJsonObject ActionMenuItem::toJson() const +{ + return QJsonObject{ + {"type", "action"}, + {"text", m_text}, + {"icon", m_icon}, + {"id", m_id}, + }; +} + +QJsonObject SectionMenuItem::toJson() const +{ + return QJsonObject{ + {"type", "section"}, + {"text", m_text}, + }; +} + +QJsonObject SeparatorMenuItem::toJson() const +{ + return QJsonObject{ + {"type", "separator"}, + }; +} + +SubMenuMenuItem::SubMenuMenuItem(std::vector> items) + : m_items(std::move(items)) +{ +} + +ContextMenuAction::~ContextMenuAction() +{ + for (const auto &future : m_futures) { + future->cancel(); + } +} + +QJsonObject SubMenuMenuItem::toJson() const +{ + QJsonArray items; + for (const auto &item : m_items) { + items.append(item->toJson()); + } + return QJsonObject{ + {"type", "menu"}, + {"text", m_text}, + {"icon", m_icon}, + {"items", items}, + }; +} + +ContextMenuAction::ContextMenuAction(std::vector> items) + : m_items(std::move(items)) +{ + QJsonArray jsonItems; + std::vector actions; + for (const auto &item : m_items) { + extractActions(*item, actions); + jsonItems.append(item->toJson()); + } + m_json = QJsonDocument(jsonItems).toJson(QJsonDocument::Compact); + + for (auto *action : actions) { + m_actions[action->id()] = action; + } +} + +void ContextMenuAction::doExecute(const ActionExecutionArguments &args) +{ + QThreadHelpers::runOnThread(QThreadHelpers::mainThread(), [this]() { + auto future = std::make_shared>(g_overlayManager->showContextMenu(m_json)); + m_futures.push_back(future); + future->then([this, future = future.get()](const QString &actionId) { + if (m_actions.contains(actionId)) { + for (auto &action : m_actions.at(actionId)->actions()) { + g_actionExecutor->execute(*action); + } + } + + std::erase_if(m_futures, [future](const auto &value) { + return value.get() == future; + }); + }); + }); +} + +void ContextMenuAction::extractActions(MenuItem &item, std::vector &result) +{ + if (auto *actionItem = dynamic_cast(&item)) { + result.push_back(actionItem); + } else if (auto *subMenuItem = dynamic_cast(&item)) { + for (const auto &item : subMenuItem->items()) { + extractActions(*item, result); + } + } +} + +} \ No newline at end of file diff --git a/src/libinputactions/actions/ContextMenuAction.h b/src/libinputactions/actions/ContextMenuAction.h new file mode 100644 index 0000000..8286cd0 --- /dev/null +++ b/src/libinputactions/actions/ContextMenuAction.h @@ -0,0 +1,123 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + 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" + +namespace InputActions +{ + +class MenuItem +{ +public: + virtual ~MenuItem() = default; + + virtual QJsonObject toJson() const = 0; + +protected: + MenuItem() = default; +}; + +class ActionMenuItem : public MenuItem +{ +public: + ActionMenuItem(std::vector> actions); + + const std::vector> &actions() const { return m_actions; } + + const QString &text() const { return m_text; } + void setText(QString value) { m_text = std::move(value); } + + const QString &icon() const { return m_icon; } + void setIcon(QString value) { m_icon = std::move(value); } + + const QString &id() const { return m_id; } + + virtual QJsonObject toJson() const override; + +private: + std::vector> m_actions; + QString m_text; + QString m_icon; + QString m_id; +}; + +class SectionMenuItem : public MenuItem +{ +public: + SectionMenuItem() = default; + + const QString &text() const { return m_text; } + void setText(QString value) { m_text = std::move(value); } + + virtual QJsonObject toJson() const override; + +private: + QString m_text; +}; + +class SeparatorMenuItem : public MenuItem +{ +public: + SeparatorMenuItem() = default; + + virtual QJsonObject toJson() const override; +}; + +class SubMenuMenuItem : public MenuItem +{ +public: + SubMenuMenuItem(std::vector> items); + + const std::vector> &items() const { return m_items; } + + const QString &text() const { return m_text; } + void setText(QString value) { m_text = std::move(value); } + + const QString &icon() const { return m_icon; } + void setIcon(QString value) { m_icon = std::move(value); } + + virtual QJsonObject toJson() const override; + +private: + std::vector> m_items; + QString m_text; + QString m_icon; +}; + +class ContextMenuAction : public Action +{ +public: + ContextMenuAction(std::vector> items); + ~ContextMenuAction() override; + +protected: + void doExecute(const ActionExecutionArguments &args) override; + +private: + static void extractActions(MenuItem &item, std::vector &result); + + std::vector> m_items; + std::map m_actions; + QString m_json; + + std::vector>> m_futures; +}; + +} \ No newline at end of file diff --git a/src/libinputactions/config/ConfigIssue.cpp b/src/libinputactions/config/ConfigIssue.cpp index 32cd0db..cb60e80 100644 --- a/src/libinputactions/config/ConfigIssue.cpp +++ b/src/libinputactions/config/ConfigIssue.cpp @@ -221,7 +221,8 @@ OverlayProgramDoesNotExistConfigException::OverlayProgramDoesNotExistConfigExcep QString OverlayProgramDoesNotExistConfigException::message() const { - return QString("Failed to find the inputactions-overlay program. It must be installed in one of the directories specified in the PATH environment variable."); + return QString("This feature requires the overlay, which appears to not be installed. Ensure 'inputactions-overlay' is present in one of the directories" + " specified in the PATH environment variable."); } UncaughtScriptErrorConfigException::UncaughtScriptErrorConfigException(const Node *node, QJSValue error) diff --git a/src/libinputactions/config/ConfigLoader.cpp b/src/libinputactions/config/ConfigLoader.cpp index ae066d8..6d13073 100644 --- a/src/libinputactions/config/ConfigLoader.cpp +++ b/src/libinputactions/config/ConfigLoader.cpp @@ -117,6 +117,7 @@ QFuture ConfigLoader::doLoad(const ConfigLoadSettings &settings) qCDebug(INPUTACTIONS, "Reloading config"); const auto rawConfig = settings.empty ? "" : m_configProvider.currentConfig(); + m_overlayRequested = false; g_configIssueManager->clearIssues(); g_variableRegistry = std::make_shared(); g_inputActions->registerGlobalVariables(g_variableRegistry.get()); @@ -229,7 +230,7 @@ QFuture> ConfigLoader::createConfig(const QString &r .onFailed([root](const PromiseException &error) { throw UncaughtScriptErrorConfigException(root.get(), error.value()); // TODO correct node }) - .then([root]() { + .then([this, root]() { g_scriptingEngine->coreModule().variableRegistry()->disableRegistration(); auto config = std::make_shared(); @@ -261,9 +262,10 @@ QFuture> ConfigLoader::createConfig(const QString &r if (const auto *overlayNode = root->mapAt("overlay")) { if (const auto *mouseStrokeNode = overlayNode->mapAt("mouse_stroke")) { - loadMember(config->enableMouseStrokeOverlay, mouseStrokeNode->at("enable")); - if (config->enableMouseStrokeOverlay && !g_overlayManager->overlayProgramExists()) { - throw OverlayProgramDoesNotExistConfigException(overlayNode); + const auto *enableNode = mouseStrokeNode->at("enable"); + loadMember(config->enableMouseStrokeOverlay, enableNode); + if (config->enableMouseStrokeOverlay) { + requestOverlay(enableNode); } } } @@ -284,8 +286,8 @@ QFuture ConfigLoader::activateConfig(std::shared_ptr config, b auto *scriptingConfig = g_scriptingEngine->coreModule().config(); - return scriptingConfig->aboutToBeActivatedSignal().emitAsync(false).then([initialize, scriptingConfig, config]() { - g_overlayManager->setEnabled(config->enableMouseStrokeOverlay); + return scriptingConfig->aboutToBeActivatedSignal().emitAsync(false).then([this, initialize, scriptingConfig, config]() { + g_overlayManager->setEnabled(m_overlayRequested); g_overlayManager->setMouseStrokeOverlayEnabled(config->enableMouseStrokeOverlay); g_globalConfig->setAllowExternalVariableAccess(config->allowExternalVariableAccess); @@ -312,6 +314,14 @@ QFuture ConfigLoader::activateConfig(std::shared_ptr config, b }); } +void ConfigLoader::requestOverlay(const Node *node) +{ + if (!g_overlayManager->overlayProgramExists()) { + throw OverlayProgramDoesNotExistConfigException(node); + } + m_overlayRequested = true; +} + void ConfigLoader::onConfigChanged() { if (!m_allowNonEmptyConfigs || !g_globalConfig->autoReload()) { diff --git a/src/libinputactions/config/ConfigLoader.h b/src/libinputactions/config/ConfigLoader.h index 62532a4..bf65986 100644 --- a/src/libinputactions/config/ConfigLoader.h +++ b/src/libinputactions/config/ConfigLoader.h @@ -28,6 +28,7 @@ namespace InputActions { struct ConfigData; +class Node; struct ConfigLoadSettings { @@ -58,6 +59,12 @@ class ConfigLoader : public QObject */ void setAllowNonEmptyConfigs(bool value) { m_allowNonEmptyConfigs = value; } + /** + * Requests that the overlay be enabled. + * @throws OverlayProgramDoesNotExistConfigException + */ + void requestOverlay(const Node *node); + private slots: void onConfigChanged(); @@ -67,6 +74,7 @@ private slots: QFuture activateConfig(std::shared_ptr config, bool initialize); QFuture m_currentFuture; + bool m_overlayRequested{}; ConfigProvider m_configProvider; bool m_allowNonEmptyConfigs = true; diff --git a/src/libinputactions/config/parsers/core.cpp b/src/libinputactions/config/parsers/core.cpp index f903680..04d8a01 100644 --- a/src/libinputactions/config/parsers/core.cpp +++ b/src/libinputactions/config/parsers/core.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -158,6 +159,9 @@ void NodeParser>::parse(const Node *node, std::unique_pt auto action = std::make_unique(commandNode->as>()); loadSetter(action, &CommandAction::setWait, node->at("wait")); result = std::move(action); + } else if (const auto *contextMenuNode = node->at("context_menu")) { + g_configLoader->requestOverlay(contextMenuNode); + result = std::make_unique(contextMenuNode->as>>()); } 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")) { @@ -563,6 +567,36 @@ void NodeParser::parse(const Node *node, KeyboardKey &result) throw InvalidValueConfigException(node, QString("Invalid keyboard key '%1'.").arg(raw)); } +template<> +void NodeParser::parse(const Node *node, KeyboardShortcut &result) +{ + loadMember(result.keys, node); +} + +template<> +void NodeParser>::parse(const Node *node, std::unique_ptr &result) +{ + if (const auto *actionsNode = node->at("actions")) { + auto actionItem = std::make_unique(actionsNode->as>>()); + loadSetter(actionItem, &ActionMenuItem::setIcon, node->at("icon")); + loadSetter(actionItem, &ActionMenuItem::setText, node->at("text")); + result = std::move(actionItem); + } else if (const auto *menuNode = node->at("menu")) { + auto subMenuItem = std::make_unique(menuNode->as>>()); + loadSetter(subMenuItem, &SubMenuMenuItem::setIcon, node->at("icon")); + loadSetter(subMenuItem, &SubMenuMenuItem::setText, node->at("text")); + result = std::move(subMenuItem); + } else if (const auto *sectionNode = node->at("section")) { + auto sectionItem = std::make_unique(); + loadSetter(sectionItem, &SectionMenuItem::setText, sectionNode); + result = std::move(sectionItem); + } else if (node->at("separator")) { + result = std::make_unique(); + } else { + throw InvalidValueConfigException(node, "Invalid menu item."); + } +} + template<> void NodeParser::parse(const Node *node, MouseButton &result) { @@ -575,12 +609,6 @@ void NodeParser::parse(const Node *node, MouseButton &result) throw InvalidValueConfigException(node, QString("Invalid mouse button '%1'.").arg(raw)); } -template<> -void NodeParser::parse(const Node *node, KeyboardShortcut &result) -{ - loadMember(result.keys, node); -} - template<> void NodeParser::parse(const Node *node, PointF &result) { diff --git a/src/libinputactions/helpers/QDBusInterface.cpp b/src/libinputactions/helpers/QDBusInterface.cpp new file mode 100644 index 0000000..b6bf7e2 --- /dev/null +++ b/src/libinputactions/helpers/QDBusInterface.cpp @@ -0,0 +1,35 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + 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 "QDBusInterface.h" + +namespace InputActions::QDBusInterfaceHelpers +{ + +DBusErrorException::DBusErrorException(QDBusError error) + : m_error(std::move(error)) + , m_message(m_error.message().toStdString()) +{ +} + +const char *DBusErrorException::what() const noexcept +{ + return m_message.c_str(); +} + +} \ No newline at end of file diff --git a/src/libinputactions/helpers/QDBusInterface.h b/src/libinputactions/helpers/QDBusInterface.h new file mode 100644 index 0000000..56fafc5 --- /dev/null +++ b/src/libinputactions/helpers/QDBusInterface.h @@ -0,0 +1,69 @@ +/* + Input Actions - Input handler that executes user-defined actions + Copyright (C) 2024-2026 Marcin Woźniak + + 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 +#include +#include +#include + +namespace InputActions::QDBusInterfaceHelpers +{ + +class DBusErrorException : public std::exception +{ +public: + DBusErrorException(QDBusError error); + + const QDBusError &error() const { return m_error; } + + const char *what() const noexcept override; + +private: + QDBusError m_error; + std::string m_message; +}; + +/** + * @returns A future that is finished with the call's return value on success or failed with a DBusErrorException. + */ +template +QFuture asyncCall(QDBusInterface &interface, const QString &method, TArgs &&...args) +{ + auto promise = std::make_shared>(); + promise->start(); + + auto call = interface.asyncCall(method, std::forward(args)...); + auto *watcher = new QDBusPendingCallWatcher(call); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, [promise](QDBusPendingCallWatcher *watcher) { + QDBusPendingReply reply(*watcher); + if (reply.isError()) { + promise->setException(std::make_exception_ptr(DBusErrorException(reply.error()))); + } else if constexpr (!std::is_same_v) { + promise->addResult(reply.template argumentAt<0>()); + } + promise->finish(); + + watcher->deleteLater(); + }); + + return promise->future(); +} + +} \ No newline at end of file diff --git a/src/libinputactions/interfaces/OverlayManager.cpp b/src/libinputactions/interfaces/OverlayManager.cpp index 0aa2354..63f8f5e 100644 --- a/src/libinputactions/interfaces/OverlayManager.cpp +++ b/src/libinputactions/interfaces/OverlayManager.cpp @@ -17,8 +17,11 @@ */ #include "OverlayManager.h" +#include #include #include +#include +#include namespace InputActions { @@ -73,6 +76,50 @@ void OverlayManager::hideMouseStrokeOverlay() } } +QFuture OverlayManager::showContextMenu(const QString &json) +{ + if (auto *interface = contextMenuOverlayInterface()) { + return QDBusInterfaceHelpers::asyncCall(*interface, "showOverlay") + .then([](const bool overlayAlreadyVisible) { + if (!overlayAlreadyVisible) { + // The compositor will not grant pointer focus to a window that opens under a stationary pointer, so we move the pointer. Scrolling doesn't + // work on Hyprland and buttons can have side effects. + g_inputBackend->virtualMouse()->mouseMotion({-2, -2}); + QTimer::singleShot(10, []() { + g_inputBackend->virtualMouse()->mouseMotion({2, 2}); + }); + } + }) + .then([interface, json]() { + return QDBusInterfaceHelpers::asyncCall(*interface, "showMenu", json); + }) + .unwrap() + .onFailed([]() { + return QString(""); + }); + } else { + return QtFuture::makeReadyValueFuture(QString("")); + } +} + +QDBusInterface *OverlayManager::contextMenuOverlayInterface() +{ + if (m_contextMenuOverlayInterface) { + return &m_contextMenuOverlayInterface.value(); + } + + m_contextMenuOverlayInterface.emplace("org.inputactions.overlay", + "/org/inputactions/overlay/ContextMenuOverlay", + "org.inputactions.overlay.ContextMenuOverlay", + QDBusConnectionHelpers::sessionBus()); + if (m_contextMenuOverlayInterface->isValid()) { + return &m_contextMenuOverlayInterface.value(); + } + + m_contextMenuOverlayInterface.reset(); + return {}; +} + QDBusInterface *OverlayManager::mouseStrokeOverlayInterface() { if (!m_mouseStrokeOverlayEnabled) { diff --git a/src/libinputactions/interfaces/OverlayManager.h b/src/libinputactions/interfaces/OverlayManager.h index 267e20a..4998bb7 100644 --- a/src/libinputactions/interfaces/OverlayManager.h +++ b/src/libinputactions/interfaces/OverlayManager.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include @@ -42,7 +43,16 @@ class OverlayManager : public QObject void hideMouseStrokeOverlay(); bool mouseStrokeOverlayVisible() const { return m_mouseStrokeOverlayVisible; } + /** + * @returns A future finished with the ID of the triggered action or an empty string. + */ + QFuture showContextMenu(const QString &json); + private: + /** + * Nullptr if not available. + */ + QDBusInterface *contextMenuOverlayInterface(); /** * Nullptr if not available. */ @@ -50,6 +60,7 @@ class OverlayManager : public QObject QProcess *m_process; + std::optional m_contextMenuOverlayInterface; std::optional m_mouseStrokeOverlayInterface; bool m_mouseStrokeOverlayVisible{}; bool m_mouseStrokeOverlayEnabled{};