diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f25dfe78..1f221f01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,12 +25,12 @@ jobs: - distro: archlinux container: archlinux:latest pkg_cache_path: /var/cache/pacman/pkg - install: pacman -Syu --noconfirm --needed cmake ninja gcc git pkgconf qt6-base qt6-declarative qt6-svg systemd-libs gtest + install: pacman -Syu --noconfirm --needed cmake ninja gcc git pkgconf qt6-base qt6-declarative qt6-svg systemd-libs gtest layer-shell-qt glib2 run_tests: false - distro: fedora-42 container: fedora:42 pkg_cache_path: /var/cache/dnf - install: dnf install -y --setopt=keepcache=1 cmake ninja-build gcc-c++ git pkgconf-pkg-config qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qtsvg-devel qt6-qtshadertools systemd-devel gtest-devel + install: dnf install -y --setopt=keepcache=1 cmake ninja-build gcc-c++ git pkgconf-pkg-config qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qtsvg-devel qt6-qtshadertools systemd-devel gtest-devel layer-shell-qt-devel glib2 run_tests: false - distro: ubuntu-24.04 container: ubuntu:24.04 @@ -181,7 +181,11 @@ jobs: deb-pkg: name: Debian Package runs-on: ubuntu-24.04 + container: debian:trixie steps: + - name: Install checkout prerequisites + run: apt-get update -qq && apt-get install -y git ca-certificates + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -199,8 +203,8 @@ jobs: - name: Install dependencies run: | mkdir -p ~/.cache/apt-archives/partial - sudo apt-get update - sudo apt-get -o Dir::Cache::archives="$HOME/.cache/apt-archives" install -y \ + apt-get update + apt-get -o Dir::Cache::archives="$HOME/.cache/apt-archives" install -y \ build-essential cmake ninja-build pkg-config dpkg-dev \ qt6-base-dev qt6-declarative-dev qt6-svg-dev \ qt6-tools-dev qt6-tools-dev-tools \ @@ -209,9 +213,9 @@ jobs: qml6-module-qtquick-templates qml6-module-qtqml-workerscript \ qml6-module-qtquick-dialogs \ qt6-qpa-plugins libqt6opengl6-dev libqt6svg6-dev \ - libxkbcommon-dev libudev-dev + libxkbcommon-dev libudev-dev liblayershellqtinterface-dev libglib2.0-bin # Ensure the cache dir is owned by the runner user before save. - sudo chown -R "$(id -u):$(id -g)" ~/.cache/apt-archives + chown -R "$(id -u):$(id -g)" ~/.cache/apt-archives - name: Build .deb run: bash scripts/package-deb.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a9a943b..0c67c5c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,11 @@ jobs: # -------------------------------------------------------------------------- package-deb: runs-on: ubuntu-24.04 + container: debian:trixie steps: + - name: Install checkout prerequisites + run: apt-get update -qq && apt-get install -y git ca-certificates + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -49,10 +53,10 @@ jobs: - name: Install dependencies run: | - echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' | \ - sudo tee /etc/apt/apt.conf.d/01keep-debs >/dev/null - sudo apt-get update - sudo apt-get install -y \ + echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > \ + /etc/apt/apt.conf.d/01keep-debs + apt-get update + apt-get install -y \ build-essential cmake ninja-build pkg-config dpkg-dev \ qt6-base-dev qt6-declarative-dev qt6-svg-dev \ qt6-tools-dev qt6-tools-dev-tools \ @@ -61,8 +65,8 @@ jobs: qml6-module-qtquick-templates qml6-module-qtqml-workerscript \ qml6-module-qtquick-dialogs qml6-module-qt5compat-graphicaleffects \ qt6-qpa-plugins libqt6opengl6-dev libqt6svg6-dev \ - libxkbcommon-dev libudev-dev - sudo chmod -R a+rX /var/cache/apt/archives + libxkbcommon-dev libudev-dev liblayershellqtinterface-dev libglib2.0-bin + chmod -R a+rX /var/cache/apt/archives - name: Build .deb run: bash scripts/package-deb.sh @@ -99,7 +103,7 @@ jobs: gcc gcc-c++ cmake ninja-build pkgconf-pkg-config rpm-build \ qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qtsvg-devel \ qt6-qttools-devel qt6-qt5compat-devel \ - libxkbcommon-devel systemd-devel grep + libxkbcommon-devel systemd-devel layer-shell-qt-devel glib2 grep - name: Build .rpm run: bash scripts/package-rpm.sh @@ -138,7 +142,7 @@ jobs: pacman -S --noconfirm --needed \ base-devel cmake ninja \ qt6-base qt6-declarative qt6-svg qt6-tools qt6-5compat \ - libxkbcommon systemd-libs sudo + libxkbcommon systemd-libs layer-shell-qt glib2 sudo - name: Create unprivileged build user (makepkg refuses root) run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 028c6eaf..a744ec87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,14 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Core Quick Svg DBus Widgets Concurrent Test QuickTest Network) +option(LOGITUNE_REQUIRE_LAYER_SHELL + "Fail configuration when LayerShellQt is unavailable" OFF) +find_package(LayerShellQt QUIET) +if(LOGITUNE_REQUIRE_LAYER_SHELL AND NOT TARGET LayerShellQt::Interface) + message(FATAL_ERROR "LayerShellQt is required for package builds") +elseif(NOT TARGET LayerShellQt::Interface) + message(WARNING "LayerShellQt not found: KDE action-ring overlay is disabled") +endif() find_package(PkgConfig REQUIRED) pkg_check_modules(UDEV REQUIRED libudev) diff --git a/pkg/obs/debian.control b/pkg/obs/debian.control index 19ba19d9..11f6923d 100644 --- a/pkg/obs/debian.control +++ b/pkg/obs/debian.control @@ -2,13 +2,13 @@ Source: logitune Section: utils Priority: optional Maintainer: mmaher88 -Build-Depends: debhelper-compat (= 13), cmake (>= 3.22), g++ (>= 12), ninja-build, pkg-config, qt6-base-dev, qt6-declarative-dev, qt6-svg-dev, qt6-base-dev-tools, qt6-declarative-dev-tools, libudev-dev +Build-Depends: debhelper-compat (= 13), cmake (>= 3.22), g++ (>= 12), ninja-build, pkg-config, qt6-base-dev, qt6-declarative-dev, qt6-svg-dev, qt6-base-dev-tools, qt6-declarative-dev-tools, libudev-dev, liblayershellqtinterface-dev Standards-Version: 4.6.2 Homepage: https://github.com/mmaher88/logitune Package: logitune Architecture: any -Depends: ${shlibs:Depends}, ${misc:Depends}, libqt6core6 (>= 6.4), libqt6quick6, libqt6svg6, libqt6dbus6, libqt6widgets6, libudev1, qml6-module-qtqml, qml6-module-qtqml-workerscript, qml6-module-qtquick, qml6-module-qtquick-controls, qml6-module-qtquick-dialogs, qml6-module-qtquick-layouts, qml6-module-qtquick-templates, qml6-module-qtquick-window +Depends: ${shlibs:Depends}, ${misc:Depends}, libqt6core6 (>= 6.4), libqt6quick6, libqt6svg6, libqt6dbus6, libqt6widgets6, libudev1, libglib2.0-bin, qml6-module-qtqml, qml6-module-qtqml-workerscript, qml6-module-qtquick, qml6-module-qtquick-controls, qml6-module-qtquick-dialogs, qml6-module-qtquick-layouts, qml6-module-qtquick-templates, qml6-module-qtquick-window Suggests: gnome-shell Description: Logitech device configurator for Linux Logitune is a Logitech Options+ alternative for Linux. Configure HID++ diff --git a/pkg/obs/debian.rules b/pkg/obs/debian.rules index 6fec3858..cef98767 100644 --- a/pkg/obs/debian.rules +++ b/pkg/obs/debian.rules @@ -8,7 +8,7 @@ override_dh_auto_configure: # DEB_VERSION_UPSTREAM comes from debian/changelog (which OBS keeps in # sync via the set_version service). CMakeLists.txt refuses to guess # without an explicit -DLOGITUNE_VERSION, so feed it here. - cmake -B build -G Ninja -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLOGITUNE_VERSION=$(DEB_VERSION_UPSTREAM) -Wno-dev + cmake -B build -G Ninja -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLOGITUNE_REQUIRE_LAYER_SHELL=ON -DLOGITUNE_VERSION=$(DEB_VERSION_UPSTREAM) -Wno-dev override_dh_auto_build: cmake --build build -j$$(nproc) diff --git a/pkg/obs/logitune.dsc b/pkg/obs/logitune.dsc index c8c6a3e7..745e8ed5 100644 --- a/pkg/obs/logitune.dsc +++ b/pkg/obs/logitune.dsc @@ -6,4 +6,4 @@ Maintainer: mmaher88 Architecture: any Homepage: https://github.com/mmaher88/logitune Standards-Version: 4.6.2 -Build-Depends: debhelper-compat (= 13), cmake (>= 3.22), g++ (>= 12), ninja-build, pkg-config, qt6-base-dev, qt6-declarative-dev, qt6-svg-dev, qt6-base-dev-tools, qt6-declarative-dev-tools, libudev-dev +Build-Depends: debhelper-compat (= 13), cmake (>= 3.22), g++ (>= 12), ninja-build, pkg-config, qt6-base-dev, qt6-declarative-dev, qt6-svg-dev, qt6-base-dev-tools, qt6-declarative-dev-tools, libudev-dev, liblayershellqtinterface-dev diff --git a/pkg/obs/logitune.spec b/pkg/obs/logitune.spec index fe92f360..06485cc6 100644 --- a/pkg/obs/logitune.spec +++ b/pkg/obs/logitune.spec @@ -17,6 +17,7 @@ BuildRequires: qt6-declarative-devel BuildRequires: qt6-svg-devel BuildRequires: qt6-shadertools-devel BuildRequires: systemd-devel +BuildRequires: layer-shell-qt6-devel %else BuildRequires: ninja-build BuildRequires: qt6-qtbase-devel @@ -24,6 +25,7 @@ BuildRequires: qt6-qtdeclarative-devel BuildRequires: qt6-qtsvg-devel BuildRequires: qt6-qtshadertools BuildRequires: systemd-devel +BuildRequires: layer-shell-qt-devel %endif %if 0%{?suse_version} @@ -34,6 +36,8 @@ Requires: qt6-svg Requires: qt6-qtbase Requires: qt6-qtdeclarative Requires: qt6-qtsvg +Requires: layer-shell-qt +Requires: glib2 %endif %description @@ -47,7 +51,7 @@ DPI, SmartShift, scroll, gesture, and thumb wheel settings. %build # OBS builds from a source tarball with no .git, so feed the version # through -DLOGITUNE_VERSION; CMakeLists.txt refuses to guess. -%cmake -DBUILD_TESTING=OFF -DLOGITUNE_VERSION=%{version} +%cmake -DBUILD_TESTING=OFF -DLOGITUNE_REQUIRE_LAYER_SHELL=ON -DLOGITUNE_VERSION=%{version} %cmake_build %install diff --git a/scripts/package-arch.sh b/scripts/package-arch.sh index b241b826..bf3bc336 100755 --- a/scripts/package-arch.sh +++ b/scripts/package-arch.sh @@ -26,15 +26,15 @@ pkgdesc="Logitech device configurator for Linux — per-app profiles, button rem arch=('x86_64') url="https://github.com/mmaher88/logitune" license=('GPL-3.0-or-later') -depends=('qt6-base' 'qt6-declarative' 'qt6-svg' 'qt6-5compat' 'systemd-libs') -makedepends=('cmake' 'ninja' 'qt6-tools') +depends=('qt6-base' 'qt6-declarative' 'qt6-svg' 'qt6-5compat' 'systemd-libs' 'layer-shell-qt' 'glib2') +makedepends=('cmake' 'ninja' 'qt6-tools' 'layer-shell-qt') optdepends=('gnome-shell: per-app profile switching on GNOME' 'gnome-shell-extension-appindicator: system tray icon on GNOME') source=() build() { cd "$startdir" - cmake -B build-pkg -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_VERSION=VERSION_PLACEHOLDER -Wno-dev + cmake -B build-pkg -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_REQUIRE_LAYER_SHELL=ON -DLOGITUNE_VERSION=VERSION_PLACEHOLDER -Wno-dev cmake --build build-pkg -j$(nproc) } diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh index ba774530..a8d74f7c 100755 --- a/scripts/package-deb.sh +++ b/scripts/package-deb.sh @@ -19,7 +19,7 @@ echo "Building .deb package v$VERSION ($ARCH)" # Build release (offscreen for headless gtest_discover_tests) export QT_QPA_PLATFORM=offscreen -cmake -B build-release -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_VERSION="$VERSION" -Wno-dev > /dev/null 2>&1 +cmake -B build-release -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_REQUIRE_LAYER_SHELL=ON -DLOGITUNE_VERSION="$VERSION" -Wno-dev > /dev/null 2>&1 cmake --build build-release -j$(nproc) # Create package structure @@ -34,7 +34,7 @@ Version: $VERSION Section: utils Priority: optional Architecture: $ARCH -Depends: libqt6core6 (>= 6.4), libqt6qml6, libqt6quick6, libqt6svg6, libqt6dbus6, libqt6widgets6, libudev1, qml6-module-qtqml, qml6-module-qtqml-workerscript, qml6-module-qtquick, qml6-module-qtquick-controls, qml6-module-qtquick-dialogs, qml6-module-qtquick-layouts, qml6-module-qtquick-templates, qml6-module-qtquick-window +Depends: libqt6core6 (>= 6.4), libqt6qml6, libqt6quick6, libqt6svg6, libqt6dbus6, libqt6widgets6, liblayershellqtinterface6, libudev1, libglib2.0-bin, qml6-module-qtqml, qml6-module-qtqml-workerscript, qml6-module-qtquick, qml6-module-qtquick-controls, qml6-module-qtquick-dialogs, qml6-module-qtquick-layouts, qml6-module-qtquick-templates, qml6-module-qtquick-window Recommends: gnome-shell-extension-appindicator Maintainer: Mina Maher Description: Logitech device configurator for Linux diff --git a/scripts/package-rpm.sh b/scripts/package-rpm.sh index 47037d94..b8ce3eb9 100755 --- a/scripts/package-rpm.sh +++ b/scripts/package-rpm.sh @@ -16,7 +16,7 @@ VERSION="${TAG//-/\~}" echo "Building .rpm package v$VERSION" # Build release -cmake -B build-release -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_VERSION="$VERSION" -Wno-dev > /dev/null 2>&1 +cmake -B build-release -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_TESTING=OFF -DLOGITUNE_REQUIRE_LAYER_SHELL=ON -DLOGITUNE_VERSION="$VERSION" -Wno-dev > /dev/null 2>&1 cmake --build build-release -j$(nproc) # Install to staging dir diff --git a/src/app/AppRoot.cpp b/src/app/AppRoot.cpp index 6e98f005..684adfc7 100644 --- a/src/app/AppRoot.cpp +++ b/src/app/AppRoot.cpp @@ -283,6 +283,8 @@ void AppRoot::onPhysicalDeviceAdded(PhysicalDevice *device) [this, device](int delta) { m_buttonDispatcher.onThumbWheelRotation(device, delta); }); + connect(device, &PhysicalDevice::stateChanged, &m_buttonDispatcher, + [this, device]() { m_buttonDispatcher.onDeviceStateChanged(device); }); // Re-apply profile whenever any transport finishes (re-)enumeration. // PhysicalDevice emits this from its setupComplete fan-in, which diff --git a/src/app/AppRoot.h b/src/app/AppRoot.h index 8f820006..5e4fbec2 100644 --- a/src/app/AppRoot.h +++ b/src/app/AppRoot.h @@ -68,6 +68,7 @@ class AppRoot : public QObject { ProfileModel *profileModel() { return &m_profileModel; } SettingsModel *settingsModel() { return &m_settingsModel; } EditorModel *editorModel() const { return m_editorModel.get(); } + RadialMenuController *radialMenu() { return m_buttonDispatcher.radialMenu(); } private slots: void onPhysicalDeviceAdded(PhysicalDevice *device); diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 397dba0f..c06d963c 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(logitune-app-lib STATIC services/ActiveDeviceResolver.cpp services/DeviceCommandHandler.cpp services/ButtonActionDispatcher.cpp + services/RadialMenuController.cpp services/ProfileOrchestrator.cpp dialogs/GitHubIssueBuilder.cpp dialogs/CrashReportDialog.cpp @@ -24,6 +25,10 @@ target_include_directories(logitune-app-lib PUBLIC ) target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets) +if(TARGET LayerShellQt::Interface) + target_link_libraries(logitune-app-lib PUBLIC LayerShellQt::Interface) + target_compile_definitions(logitune-app-lib PRIVATE LOGITUNE_LAYER_SHELL) +endif() target_compile_definitions(logitune-app-lib PUBLIC PROJECT_VERSION="${PROJECT_VERSION}" PROJECT_VERSION_FULL="${LOGITUNE_DISPLAY_VERSION}" @@ -40,7 +45,7 @@ else() SideNav BatteryChip LogituneSlider LogituneToggle InfoCallout DetailPanel KeystrokeCapture ButtonCallout ActionsPanel DeviceCard DeviceRender AppProfileBar Toast EditorToolbar - EditableText ConflictBanner DiffModal HotspotControl) + EditableText ConflictBanner DiffModal HotspotControl RadialMenuRing) set_source_files_properties(qml/components/${_file}.qml PROPERTIES QT_RESOURCE_ALIAS qml/${_file}.qml) endforeach() @@ -58,6 +63,7 @@ qt_add_qml_module(logitune-app-lib QML_FILES qml/Theme.qml qml/Main.qml + qml/RadialMenuOverlay.qml qml/HomeView.qml qml/DeviceView.qml qml/AppSettingsView.qml @@ -79,6 +85,7 @@ qt_add_qml_module(logitune-app-lib qml/components/ConflictBanner.qml qml/components/DiffModal.qml qml/components/HotspotControl.qml + qml/components/RadialMenuRing.qml qml/pages/PointScrollPage.qml qml/pages/ButtonsPage.qml qml/pages/EasySwitchPage.qml diff --git a/src/app/main.cpp b/src/app/main.cpp index 1a9b3069..e8bf3faf 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -200,6 +200,7 @@ int main(int argc, char *argv[]) qmlRegisterSingletonInstance("Logitune", 1, 0, "GestureActionModel", controller.gestureActionFilterModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "ProfileModel", controller.profileModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "SettingsModel", controller.settingsModel()); + qmlRegisterSingletonInstance("Logitune", 1, 0, "RadialMenu", controller.radialMenu()); #else engine.rootContext()->setContextProperty("DeviceModel", controller.deviceModel()); engine.rootContext()->setContextProperty("ButtonModel", controller.buttonModel()); @@ -207,6 +208,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("GestureActionModel", controller.gestureActionFilterModel()); engine.rootContext()->setContextProperty("ProfileModel", controller.profileModel()); engine.rootContext()->setContextProperty("SettingsModel", controller.settingsModel()); + engine.rootContext()->setContextProperty("RadialMenu", controller.radialMenu()); // Qt 6.4: QML singletons from qmldir with "prefer :" don't resolve correctly. // Explicitly register Theme from its resource path. diff --git a/src/app/models/ActionFilterModel.cpp b/src/app/models/ActionFilterModel.cpp index 874397ed..f519cc08 100644 --- a/src/app/models/ActionFilterModel.cpp +++ b/src/app/models/ActionFilterModel.cpp @@ -42,7 +42,8 @@ bool ActionFilterModel::filterAcceptsRow(int sourceRow, // stay in the list; the dispatcher currently no-ops on them and // can be extended later to handle them as gesture actions. if (m_gestureMode) { - if (type == QLatin1String("gesture-trigger")) return false; + if (type == QLatin1String("gesture-trigger") + || type == QLatin1String("radial-menu")) return false; if (type == QLatin1String("none")) return false; } @@ -59,6 +60,9 @@ bool ActionFilterModel::filterAcceptsRow(int sourceRow, return m_desktop->resolveNamedAction(id).has_value(); } + if (type == QLatin1String("radial-menu")) + return m_desktop && m_desktop->supportsActionRing(); + // Capability filtering (unchanged): hide actions whose required device // capability is absent on the current device. if (!m_deviceModel || m_deviceModel->selectedIndex() < 0) diff --git a/src/app/models/ActionModel.cpp b/src/app/models/ActionModel.cpp index 98b11ee9..371dce17 100644 --- a/src/app/models/ActionModel.cpp +++ b/src/app/models/ActionModel.cpp @@ -10,6 +10,7 @@ ActionModel::ActionModel(QObject *parent) // Device { "DPI cycle", "Step through the device's DPI preset list", "dpi-cycle", "", "Device" }, { "Gestures", "Trigger gesture recognition", "gesture-trigger", "", "Device" }, + { "Action ring", "Open the circular action overlay", "radial-menu", "", "Device" }, { "Shift wheel mode", "Toggle scroll wheel ratchet/freespin", "smartshift-toggle", "", "Device" }, // Edit @@ -125,6 +126,8 @@ QString ActionModel::buttonActionToName(const ButtonAction &ba) const return QString(); if (ba.type == ButtonAction::GestureTrigger) return QStringLiteral("Gestures"); + if (ba.type == ButtonAction::RadialMenu) + return QStringLiteral("Action ring"); if (ba.type == ButtonAction::PresetRef) { for (const auto &a : m_actions) { if (a.actionType == QStringLiteral("preset") && a.payload == ba.payload) @@ -148,6 +151,8 @@ ButtonAction ActionModel::buttonEntryToAction(const QString &actionType, const Q return {ButtonAction::Default, {}}; if (actionType == QStringLiteral("gesture-trigger")) return {ButtonAction::GestureTrigger, {}}; + if (actionType == QStringLiteral("radial-menu")) + return {ButtonAction::RadialMenu, {}}; if (actionType == QStringLiteral("smartshift-toggle")) return {ButtonAction::SmartShiftToggle, {}}; if (actionType == QStringLiteral("dpi-cycle")) diff --git a/src/app/qml/Main.qml b/src/app/qml/Main.qml index c8f2f41e..adcf7c6d 100644 --- a/src/app/qml/Main.qml +++ b/src/app/qml/Main.qml @@ -6,6 +6,8 @@ import Logitune ApplicationWindow { id: root + RadialMenuOverlay { } + // ── Window geometry ───────────────────────────────────────────────── // Design minimum keeps the Buttons page sane: the right action // panel needs ~360px wide plus the main area needs ~560px for the diff --git a/src/app/qml/RadialMenuOverlay.qml b/src/app/qml/RadialMenuOverlay.qml new file mode 100644 index 00000000..5660e808 --- /dev/null +++ b/src/app/qml/RadialMenuOverlay.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Window +import Logitune + +Window { + id: overlay + width: 380 + height: 380 + x: RadialMenu.overlayPosition.x + y: RadialMenu.overlayPosition.y + visible: RadialMenu.visible + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint + + Component.onCompleted: RadialMenu.configureWindow(overlay) + + RadialMenuRing { + anchors.fill: parent + entries: RadialMenu.items + onActivated: function(index) { RadialMenu.activate(index) } + onCancelled: RadialMenu.cancel() + } +} diff --git a/src/app/qml/components/RadialMenuRing.qml b/src/app/qml/components/RadialMenuRing.qml new file mode 100644 index 00000000..674ca57c --- /dev/null +++ b/src/app/qml/components/RadialMenuRing.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Controls +import Logitune + +Item { + id: ring + property var entries: [] + signal activated(int index) + signal cancelled() + + Rectangle { + anchors.centerIn: parent + width: 96; height: 96; radius: 48 + color: Theme.surface + border.color: Theme.border + border.width: 1 + Text { + anchors.centerIn: parent + text: "Cancel" + color: Theme.text + font.pixelSize: 13 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: ring.cancelled() + } + } + + Repeater { + model: ring.entries + delegate: Rectangle { + required property int index + required property var modelData + readonly property real angle: -Math.PI / 2 + index * 2 * Math.PI / Math.max(1, ring.entries.length) + width: 112; height: 48; radius: 24 + x: ring.width / 2 + Math.cos(angle) * 132 - width / 2 + y: ring.height / 2 + Math.sin(angle) * 132 - height / 2 + color: hover.hovered ? Theme.accent : Theme.surface + border.color: Theme.border + border.width: 1 + Text { + anchors.centerIn: parent + width: parent.width - 18 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + text: modelData.label + color: hover.hovered ? "white" : Theme.text + font.pixelSize: 12 + } + HoverHandler { id: hover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: ring.activated(index) + } + } + } +} diff --git a/src/app/services/ButtonActionDispatcher.cpp b/src/app/services/ButtonActionDispatcher.cpp index 284cf21c..e9aedca4 100644 --- a/src/app/services/ButtonActionDispatcher.cpp +++ b/src/app/services/ButtonActionDispatcher.cpp @@ -18,15 +18,41 @@ ButtonActionDispatcher::ButtonActionDispatcher(ProfileEngine *profileEngine, , m_profileEngine(profileEngine) , m_actionExecutor(actionExecutor) , m_desktop(desktop) -{} + , m_radialMenu(desktop, this) +{ + connect(&m_radialMenu, &RadialMenuController::triggered, + this, &ButtonActionDispatcher::executeRadialAction); + connect(&m_radialMenu, &RadialMenuController::dismissed, this, [this]() { + m_radialDevice.clear(); + m_radialSerial.clear(); + m_radialControl = 0; + }); +} + +void ButtonActionDispatcher::cancelRadialMenu() +{ + m_radialMenu.cancel(); + m_radialDevice.clear(); + m_radialSerial.clear(); + m_radialControl = 0; +} void ButtonActionDispatcher::onDeviceRemoved(const QString &serial) { + if (serial == m_radialSerial) cancelRadialMenu(); m_state.remove(serial); } +void ButtonActionDispatcher::onDeviceStateChanged(PhysicalDevice *device) +{ + if (device != m_radialDevice) return; + DeviceSession *session = device ? device->primary() : nullptr; + if (!session || !session->isConnected()) cancelRadialMenu(); +} + void ButtonActionDispatcher::onProfileApplied(const QString &serial) { + if (serial == m_radialSerial) cancelRadialMenu(); auto it = m_state.find(serial); if (it != m_state.end()) it->thumbAccum = 0; } @@ -50,6 +76,12 @@ void ButtonActionDispatcher::onDivertedButtonPressed(PhysicalDevice *device, if (serial.isEmpty()) return; auto &state = m_state[serial]; + if (!pressed && serial == m_radialSerial + && (controlId == 0 || controlId == m_radialControl)) { + m_radialControl = 0; + return; + } + const Profile &hwProfile = m_profileEngine->cachedProfile( serial, m_profileEngine->hardwareProfile(serial)); @@ -117,6 +149,24 @@ void ButtonActionDispatcher::onDivertedButtonPressed(PhysicalDevice *device, if (ba.type == ButtonAction::Default) return; + if (ba.type == ButtonAction::RadialMenu) { + if (!m_desktop || !m_desktop->supportsActionRing()) return; + if (m_radialMenu.isActive()) { + if (m_radialDevice == device && m_radialControl == controlId) return; + cancelRadialMenu(); + return; + } + state.gestureActive = false; + state.gestureAccumX = 0; + state.gestureAccumY = 0; + m_radialDevice = device; + m_radialSerial = serial; + m_radialControl = controlId; + m_radialMenu.begin(hwProfile.radialMenu); + if (!m_radialMenu.isActive()) cancelRadialMenu(); + return; + } + if (ba.type == ButtonAction::SmartShiftToggle) { bool current = session->smartShiftEnabled(); session->setSmartShift(!current, session->smartShiftThreshold()); @@ -150,6 +200,36 @@ void ButtonActionDispatcher::onDivertedButtonPressed(PhysicalDevice *device, } } +void ButtonActionDispatcher::executeRadialAction(const ButtonAction &action) +{ + QPointer device = m_radialDevice; + const QString serial = m_radialSerial; + cancelRadialMenu(); + DeviceSession *session = device ? device->primary() : nullptr; + if (!session) return; + + if (action.type == ButtonAction::SmartShiftToggle) { + const bool enabled = !session->smartShiftEnabled(); + session->setSmartShift(enabled, session->smartShiftThreshold()); + emit smartShiftChangedByButton(serial, enabled, session->smartShiftThreshold()); + return; + } + if (action.type == ButtonAction::DpiCycle) { + session->cycleDpi(); + emit dpiChangedByButton(serial, session->currentDPI()); + return; + } + + ButtonAction toFire = action; + if (toFire.type == ButtonAction::PresetRef) { + if (!m_desktop) return; + const auto resolved = m_desktop->resolveNamedAction(toFire.payload); + if (!resolved.has_value()) return; + toFire = *resolved; + } + m_actionExecutor->executeAction(toFire); +} + void ButtonActionDispatcher::onThumbWheelRotation(PhysicalDevice *device, int delta) { DeviceSession *session = device ? device->primary() : nullptr; diff --git a/src/app/services/ButtonActionDispatcher.h b/src/app/services/ButtonActionDispatcher.h index c88ada09..b87a0f6b 100644 --- a/src/app/services/ButtonActionDispatcher.h +++ b/src/app/services/ButtonActionDispatcher.h @@ -1,7 +1,9 @@ #pragma once #include #include +#include #include +#include "RadialMenuController.h" #include namespace logitune::test { @@ -34,6 +36,8 @@ class ButtonActionDispatcher : public QObject { QObject *parent = nullptr); void onDeviceRemoved(const QString &serial); + void onDeviceStateChanged(PhysicalDevice *device); + RadialMenuController *radialMenu() { return &m_radialMenu; } friend class test::AppRootFixture; friend class test::ButtonActionDispatcherFixture; @@ -64,9 +68,16 @@ public slots: static constexpr int kGestureThreshold = 50; static constexpr int kThumbThreshold = 15; + void executeRadialAction(const ButtonAction &action); + void cancelRadialMenu(); + ProfileEngine *m_profileEngine; ActionExecutor *m_actionExecutor; IDesktopIntegration *m_desktop = nullptr; + RadialMenuController m_radialMenu; + QPointer m_radialDevice; + QString m_radialSerial; + uint16_t m_radialControl = 0; QMap m_state; // keyed by PhysicalDevice serial }; diff --git a/src/app/services/RadialMenuController.cpp b/src/app/services/RadialMenuController.cpp new file mode 100644 index 00000000..438b9e1b --- /dev/null +++ b/src/app/services/RadialMenuController.cpp @@ -0,0 +1,118 @@ +#include "RadialMenuController.h" +#include "interfaces/IDesktopIntegration.h" +#include +#include +#include +#include + +#ifdef LOGITUNE_LAYER_SHELL +#include +#endif + +namespace logitune { + +RadialMenuController::RadialMenuController(IDesktopIntegration *desktop, QObject *parent) + : QObject(parent), m_desktop(desktop) +{ + if (!m_desktop) return; + connect(m_desktop, &IDesktopIntegration::actionRingPlacementReady, this, + [this](int requestId, const QPoint &position, const QRect &screenGeometry, + const QPoint &cursorPosition) { + if (!m_active || requestId != m_requestId) return; + m_overlayPosition = position; + m_screenGeometry = screenGeometry; + m_cursorPosition = cursorPosition; + if (m_window) { + m_window->resize(kOverlaySize, kOverlaySize); + if (QScreen *screen = QGuiApplication::screenAt(cursorPosition)) + m_window->setScreen(screen); +#ifdef LOGITUNE_LAYER_SHELL + if (m_layerShell) { + if (QScreen *screen = m_window->screen()) { + const QPoint local = position - screen->geometry().topLeft(); + LayerShellQt::Window::get(m_window)->setMargins( + QMargins(local.x(), local.y(), 0, 0)); + } + } +#endif + m_window->setPosition(position); + } + m_visible = true; + emit changed(); + }); + connect(m_desktop, &IDesktopIntegration::actionRingPlacementFailed, this, + [this](int requestId) { + if (m_active && requestId == m_requestId) cancel(); + }); +} + +QVariantList RadialMenuController::items() const +{ + QVariantList result; + result.reserve(m_config.items.size()); + for (const RadialMenuItem &item : m_config.items) { + result.push_back(QVariantMap{{QStringLiteral("label"), item.label}, + {QStringLiteral("action"), item.action.serialize()}}); + } + return result; +} + +void RadialMenuController::begin(const RadialMenuConfig &config) +{ + close(false); + if (!m_desktop || !m_desktop->supportsActionRing() || !config.isValid()) return; + m_config = config; + m_active = true; + emit itemsChanged(); + ++m_requestId; + m_desktop->requestActionRingPlacement( + m_requestId, QSize(kOverlaySize, kOverlaySize), + QPoint(kOverlaySize / 2, 165)); +} + +void RadialMenuController::activate(int index) +{ + if (!m_visible || index < 0 || index >= m_config.items.size()) return; + const ButtonAction action = m_config.items.at(index).action; + close(false); + emit triggered(action); +} + +void RadialMenuController::cancel() +{ + close(true); +} + +void RadialMenuController::close(bool notifyDismissed) +{ + if (m_desktop && m_active) + m_desktop->cancelActionRingPlacement(m_requestId); + const bool changedState = m_active || m_visible; + m_active = false; + m_visible = false; + if (changedState) emit changed(); + if (changedState && notifyDismissed) emit dismissed(); +} + +void RadialMenuController::configureWindow(QWindow *window) +{ + m_window = window; +#ifdef LOGITUNE_LAYER_SHELL + if (window && m_desktop && m_desktop->supportsActionRing() + && QGuiApplication::platformName().startsWith(QStringLiteral("wayland"))) { + auto *layer = LayerShellQt::Window::get(window); + layer->setLayer(LayerShellQt::Window::LayerOverlay); + layer->setAnchors(LayerShellQt::Window::Anchors(LayerShellQt::Window::AnchorTop) + | LayerShellQt::Window::AnchorLeft); + layer->setExclusiveZone(-1); + layer->setKeyboardInteractivity( + LayerShellQt::Window::KeyboardInteractivityNone); + layer->setScope(QStringLiteral("logitune-action-ring")); + m_layerShell = true; + } +#else + Q_UNUSED(window) +#endif +} + +} // namespace logitune diff --git a/src/app/services/RadialMenuController.h b/src/app/services/RadialMenuController.h new file mode 100644 index 00000000..5265090c --- /dev/null +++ b/src/app/services/RadialMenuController.h @@ -0,0 +1,59 @@ +#pragma once + +#include "RadialMenuConfig.h" +#include +#include +#include +#include + +class QWindow; + +namespace logitune { + +class IDesktopIntegration; + +class RadialMenuController : public QObject { + Q_OBJECT + Q_PROPERTY(bool visible READ visible NOTIFY changed) + Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged) + Q_PROPERTY(QRect screenGeometry READ screenGeometry NOTIFY changed) + Q_PROPERTY(QPoint overlayPosition READ overlayPosition NOTIFY changed) + Q_PROPERTY(QPoint cursorPosition READ cursorPosition NOTIFY changed) +public: + explicit RadialMenuController(IDesktopIntegration *desktop, + QObject *parent = nullptr); + + bool visible() const { return m_visible; } + bool isActive() const { return m_active; } + QVariantList items() const; + QRect screenGeometry() const { return m_screenGeometry; } + QPoint overlayPosition() const { return m_overlayPosition; } + QPoint cursorPosition() const { return m_cursorPosition; } + + void begin(const RadialMenuConfig &config); + Q_INVOKABLE void activate(int index); + Q_INVOKABLE void cancel(); + Q_INVOKABLE void configureWindow(QWindow *window); + +signals: + void changed(); + void itemsChanged(); + void triggered(const logitune::ButtonAction &action); + void dismissed(); + +private: + void close(bool notifyDismissed); + static constexpr int kOverlaySize = 380; + IDesktopIntegration *m_desktop = nullptr; + QPointer m_window; + RadialMenuConfig m_config; + QRect m_screenGeometry; + QPoint m_overlayPosition; + QPoint m_cursorPosition; + int m_requestId = 0; + bool m_active = false; + bool m_visible = false; + bool m_layerShell = false; +}; + +} // namespace logitune diff --git a/src/core/ButtonAction.h b/src/core/ButtonAction.h index f3dd41d6..2d3849c3 100644 --- a/src/core/ButtonAction.h +++ b/src/core/ButtonAction.h @@ -14,6 +14,7 @@ struct ButtonAction { DBus, Media, PresetRef, + RadialMenu, }; Type type = Default; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 7261448b..094315d8 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(logitune-core PRIVATE hidpp/capabilities/SmartShiftCapability.cpp hidpp/capabilities/ReprogControlsCapability.cpp ProfileEngine.cpp + RadialMenuConfig.cpp ActionExecutor.cpp DeviceRegistry.cpp devices/DescriptorWriter.cpp @@ -50,5 +51,8 @@ qt_add_resources(logitune-core "action-presets" ) target_include_directories(logitune-core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(logitune-core PUBLIC Qt6::Core Qt6::DBus Qt6::Network ${UDEV_LIBRARIES}) +target_link_libraries(logitune-core PUBLIC Qt6::Core Qt6::DBus Qt6::Gui Qt6::Network ${UDEV_LIBRARIES}) +if(TARGET LayerShellQt::Interface) + target_compile_definitions(logitune-core PUBLIC LOGITUNE_LAYER_SHELL) +endif() target_include_directories(logitune-core PRIVATE ${UDEV_INCLUDE_DIRS}) diff --git a/src/core/ProfileEngine.cpp b/src/core/ProfileEngine.cpp index 647586ae..a8ff8f52 100644 --- a/src/core/ProfileEngine.cpp +++ b/src/core/ProfileEngine.cpp @@ -26,6 +26,9 @@ ButtonAction ButtonAction::parse(const QString &str) if (str == "dpi-cycle") return {DpiCycle, {}}; + if (str == "radial-menu") + return {RadialMenu, {}}; + // Prefixed forms: "type:payload" const int colon = str.indexOf(':'); if (colon == -1) @@ -59,6 +62,7 @@ QString ButtonAction::serialize() const case DBus: return "dbus:" + payload; case AppLaunch: return "app-launch:" + payload; case PresetRef: return "preset:" + payload; + case RadialMenu: return "radial-menu"; } return "default"; } @@ -88,6 +92,7 @@ Profile ProfileEngine::loadProfile(const QString &path) p.thumbWheelMode = s.value("ThumbWheel/mode", "scroll").toString(); p.thumbWheelInvert = s.value("ThumbWheel/invert", false).toBool(); + p.radialMenu = RadialMenuConfig::load(s); s.beginGroup("Buttons"); const QStringList buttonKeys = s.childKeys(); @@ -129,6 +134,8 @@ void ProfileEngine::saveProfile(const QString &path, const Profile &profile) s.setValue("ThumbWheel/mode", profile.thumbWheelMode); s.setValue("ThumbWheel/invert", profile.thumbWheelInvert); + profile.radialMenu.save(s); + s.beginGroup("Buttons"); for (std::size_t i = 0; i < profile.buttons.size(); ++i) s.setValue(QString::number(static_cast(i)), profile.buttons[i].serialize()); diff --git a/src/core/ProfileEngine.h b/src/core/ProfileEngine.h index 2f317fd0..325b97ad 100644 --- a/src/core/ProfileEngine.h +++ b/src/core/ProfileEngine.h @@ -1,5 +1,6 @@ #pragma once #include "ButtonAction.h" +#include "RadialMenuConfig.h" #include #include #include @@ -23,6 +24,7 @@ struct Profile { std::map gestures; // "up","down","left","right","click" QString thumbWheelMode = "scroll"; // "scroll", "zoom", "volume", "none" bool thumbWheelInvert = false; + RadialMenuConfig radialMenu; }; struct ProfileDelta { diff --git a/src/core/RadialMenuConfig.cpp b/src/core/RadialMenuConfig.cpp new file mode 100644 index 00000000..05275ad5 --- /dev/null +++ b/src/core/RadialMenuConfig.cpp @@ -0,0 +1,125 @@ +#include "RadialMenuConfig.h" + +#include "ActionExecutor.h" + +#include + +namespace logitune { +namespace { + +RadialMenuItem fallbackItem(int index) +{ + const auto defaults = RadialMenuConfig::defaultItems(); + if (index >= 0 && index < defaults.size()) + return defaults.at(index); + return {QStringLiteral("Action %1").arg(index + 1), {ButtonAction::Default, {}}}; +} + +} // namespace + +QList RadialMenuConfig::defaultItems() +{ + return { + {QStringLiteral("Copy"), {ButtonAction::Keystroke, QStringLiteral("Ctrl+C")}}, + {QStringLiteral("Paste"), {ButtonAction::Keystroke, QStringLiteral("Ctrl+V")}}, + {QStringLiteral("Undo"), {ButtonAction::Keystroke, QStringLiteral("Ctrl+Z")}}, + {QStringLiteral("Redo"), {ButtonAction::Keystroke, QStringLiteral("Ctrl+Shift+Z")}}, + }; +} + +RadialMenuConfig RadialMenuConfig::defaults() +{ + return {}; +} + +bool RadialMenuConfig::validAction(const ButtonAction &action) +{ + switch (action.type) { + case ButtonAction::Default: + case ButtonAction::SmartShiftToggle: + case ButtonAction::DpiCycle: + return true; + case ButtonAction::Keystroke: + case ButtonAction::Media: + return !action.payload.trimmed().isEmpty() + && !ActionExecutor::parseKeystroke(action.payload).empty(); + case ButtonAction::AppLaunch: + case ButtonAction::DBus: + case ButtonAction::PresetRef: + return !action.payload.trimmed().isEmpty(); + case ButtonAction::GestureTrigger: + case ButtonAction::RadialMenu: + return false; + } + return false; +} + +bool RadialMenuConfig::isValid() const +{ + if (items.size() < 2 || items.size() > 8) + return false; + return std::all_of(items.cbegin(), items.cend(), [](const RadialMenuItem &item) { + return !item.label.trimmed().isEmpty() && validAction(item.action); + }); +} + +RadialMenuConfig RadialMenuConfig::load(QSettings &settings) +{ + const RadialMenuConfig fallback = defaults(); + if (!settings.contains(QStringLiteral("ActionRing/count"))) + return fallback; + + bool countOk = false; + const int storedCount = settings.value(QStringLiteral("ActionRing/count")).toInt(&countOk); + const int count = countOk && storedCount >= 2 && storedCount <= 8 + ? storedCount : fallback.items.size(); + + RadialMenuConfig result; + result.items.clear(); + result.items.reserve(count); + for (int i = 0; i < count; ++i) { + const RadialMenuItem fallbackEntry = fallbackItem(i); + const QString prefix = QStringLiteral("ActionRing/Items/%1/").arg(i); + + QString label = settings.value(prefix + QStringLiteral("label"), + fallbackEntry.label).toString().trimmed(); + if (label.isEmpty()) + label = fallbackEntry.label; + + const QString serialized = settings.value( + prefix + QStringLiteral("action"), fallbackEntry.action.serialize()).toString(); + ButtonAction action = ButtonAction::parse(serialized); + if (action.serialize() != serialized || !validAction(action)) + action = fallbackEntry.action; + + result.items.append({label, action}); + } + return result; +} + +void RadialMenuConfig::save(QSettings &settings) const +{ + RadialMenuConfig clean = *this; + if (clean.items.size() < 2 || clean.items.size() > 8) + clean = defaults(); + else { + for (int i = 0; i < clean.items.size(); ++i) { + const RadialMenuItem fallback = fallbackItem(i); + clean.items[i].label = clean.items[i].label.trimmed(); + if (clean.items[i].label.isEmpty()) + clean.items[i].label = fallback.label; + if (!validAction(clean.items[i].action)) + clean.items[i].action = fallback.action; + } + } + settings.remove(QStringLiteral("ActionRing")); + settings.setValue(QStringLiteral("ActionRing/count"), clean.items.size()); + for (int i = 0; i < clean.items.size(); ++i) { + const QString prefix = QStringLiteral("ActionRing/Items/%1/").arg(i); + settings.setValue(prefix + QStringLiteral("label"), clean.items.at(i).label); + settings.setValue(prefix + QStringLiteral("action"), + clean.items.at(i).action.serialize()); + } +} + +} // namespace logitune diff --git a/src/core/RadialMenuConfig.h b/src/core/RadialMenuConfig.h new file mode 100644 index 00000000..a8b5f2f8 --- /dev/null +++ b/src/core/RadialMenuConfig.h @@ -0,0 +1,30 @@ +#pragma once + +#include "ButtonAction.h" + +#include +#include +#include + +namespace logitune { + +struct RadialMenuItem { + QString label; + ButtonAction action; + + bool operator==(const RadialMenuItem &other) const = default; +}; + +struct RadialMenuConfig { + QList items = defaultItems(); + + bool isValid() const; + static bool validAction(const ButtonAction &action); + + static QList defaultItems(); + static RadialMenuConfig defaults(); + static RadialMenuConfig load(QSettings &settings); + void save(QSettings &settings) const; +}; + +} // namespace logitune diff --git a/src/core/desktop/KDeDesktop.cpp b/src/core/desktop/KDeDesktop.cpp index a57b3268..b8956470 100644 --- a/src/core/desktop/KDeDesktop.cpp +++ b/src/core/desktop/KDeDesktop.cpp @@ -2,19 +2,187 @@ #include "logging/LogManager.h" #include "actions/ActionPresetRegistry.h" #include +#include +#include +#include +#include #include #include +#include +#include #include #include #include +#include #include #include +#include +#include namespace logitune { +namespace { + +QDBusMessage scriptingCall(const QString &method) +{ + return QDBusMessage::createMethodCall(QStringLiteral("org.kde.KWin"), + QStringLiteral("/Scripting"), + QStringLiteral("org.kde.kwin.Scripting"), + method); +} + +void unloadScript(const QString &name) +{ + if (name.isEmpty()) return; + QDBusMessage message = scriptingCall(QStringLiteral("unloadScript")); + message << name; + QDBusConnection::sessionBus().asyncCall(message); +} + +} // namespace KDeDesktop::KDeDesktop(QObject *parent) : LinuxDesktopBase(parent) { + static int instance = 0; + m_actionRingObjectPath = QStringLiteral("/Logitune/KDeDesktop%1").arg(++instance); + m_actionRingTimeout.setSingleShot(true); + m_actionRingTimeout.setInterval(200); + connect(&m_actionRingTimeout, &QTimer::timeout, + this, &KDeDesktop::actionRingRequestFailed); +} + +KDeDesktop::~KDeDesktop() +{ + if (m_actionRingRequest >= 0) + cancelActionRingPlacement(m_actionRingRequest); + if (m_actionRingObjectRegistered) + QDBusConnection::sessionBus().unregisterObject(m_actionRingObjectPath); +} + +QPoint KDeDesktop::actionRingPlacement(const QPoint &cursor, const QRect &available, + const QSize &windowSize, const QPoint &anchor) +{ + const QPoint desired = cursor - anchor; + const int maxX = std::max(available.left(), available.x() + available.width() + - windowSize.width()); + const int maxY = std::max(available.top(), available.y() + available.height() + - windowSize.height()); + return {std::clamp(desired.x(), available.left(), maxX), + std::clamp(desired.y(), available.top(), maxY)}; +} + +void KDeDesktop::cancelActionRingPlacement(int requestId) +{ + if (requestId != m_actionRingRequest) return; + m_actionRingTimeout.stop(); + m_actionRingRequest = -1; + unloadScript(m_actionRingScriptName); + m_actionRingScriptName.clear(); +} + +void KDeDesktop::actionRingRequestFailed() +{ + if (m_actionRingRequest < 0) return; + const int requestId = m_actionRingRequest; + cancelActionRingPlacement(requestId); + emit actionRingPlacementFailed(requestId); +} + +void KDeDesktop::actionRingCursorPosition(int requestId, int x, int y) +{ + if (requestId != m_actionRingRequest) return; + const QPoint cursor(x, y); + QScreen *screen = QGuiApplication::screenAt(cursor); + if (!screen) screen = QGuiApplication::primaryScreen(); + if (!screen) { + actionRingRequestFailed(); + return; + } + const QRect geometry = screen->availableGeometry(); + const QPoint position = actionRingPlacement(cursor, geometry, m_actionRingSize, + m_actionRingAnchor); + cancelActionRingPlacement(requestId); + emit actionRingPlacementReady(requestId, position, geometry, cursor); +} + +void KDeDesktop::requestActionRingPlacement(int requestId, const QSize &windowSize, + const QPoint &anchor) +{ + if (m_actionRingRequest >= 0) + cancelActionRingPlacement(m_actionRingRequest); + m_actionRingRequest = requestId; + m_actionRingSize = windowSize; + m_actionRingAnchor = anchor; + + if (!QGuiApplication::platformName().startsWith(QStringLiteral("wayland"))) { + const QPoint cursor = QCursor::pos(); + actionRingCursorPosition(requestId, cursor.x(), cursor.y()); + return; + } + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!m_actionRingObjectRegistered) { + m_actionRingObjectRegistered = bus.registerObject( + m_actionRingObjectPath, this, QDBusConnection::ExportAllSlots); + } + if (!m_actionRingObjectRegistered) { + actionRingRequestFailed(); + return; + } + + m_actionRingScriptName = QStringLiteral("logitune_action_ring_%1_%2_%3") + .arg(QCoreApplication::applicationPid()) + .arg(m_actionRingObjectPath.section('/', -1)) + .arg(requestId); + const QString scriptName = m_actionRingScriptName; + auto script = std::make_shared( + QDir::tempPath() + QStringLiteral("/logitune-action-ring-XXXXXX.js")); + if (!script->open()) { + actionRingRequestFailed(); + return; + } + const QByteArray source = QStringLiteral( + "var p = workspace.cursorPos;\n" + "callDBus('%1', '%2', 'com.logitune.KDeDesktop', " + "'actionRingCursorPosition', %3, p.x, p.y);\n") + .arg(bus.baseService(), m_actionRingObjectPath) + .arg(requestId) + .toUtf8(); + if (script->write(source) != source.size() || !script->flush()) { + actionRingRequestFailed(); + return; + } + + QDBusMessage load = scriptingCall(QStringLiteral("loadScript")); + load << script->fileName() << scriptName; + m_actionRingTimeout.start(); + auto *watcher = new QDBusPendingCallWatcher(bus.asyncCall(load, 200), this); + connect(watcher, &QDBusPendingCallWatcher::finished, watcher, + [this, watcher, requestId, scriptName, script]() { + const QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (requestId != m_actionRingRequest) { + unloadScript(scriptName); + return; + } + if (reply.isError() || reply.value() < 0) { + actionRingRequestFailed(); + return; + } + QDBusMessage run = QDBusMessage::createMethodCall( + QStringLiteral("org.kde.KWin"), + QStringLiteral("/Scripting/Script%1").arg(reply.value()), + QStringLiteral("org.kde.kwin.Script"), QStringLiteral("run")); + auto *running = new QDBusPendingCallWatcher( + QDBusConnection::sessionBus().asyncCall(run, 200), this); + connect(running, &QDBusPendingCallWatcher::finished, running, + [this, running, requestId, script]() { + const QDBusPendingReply<> result = *running; + running->deleteLater(); + if (requestId == m_actionRingRequest && result.isError()) + actionRingRequestFailed(); + }); + }); } void KDeDesktop::start() diff --git a/src/core/desktop/KDeDesktop.h b/src/core/desktop/KDeDesktop.h index ee4e8054..f84f9e3d 100644 --- a/src/core/desktop/KDeDesktop.h +++ b/src/core/desktop/KDeDesktop.h @@ -2,6 +2,9 @@ #include "desktop/LinuxDesktopBase.h" #include #include +#include +#include +#include #include namespace logitune { @@ -10,8 +13,10 @@ class ActionPresetRegistry; class KDeDesktop : public LinuxDesktopBase { Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "com.logitune.KDeDesktop") public: explicit KDeDesktop(QObject *parent = nullptr); + ~KDeDesktop() override; void start() override; bool available() const override; @@ -20,6 +25,19 @@ class KDeDesktop : public LinuxDesktopBase { void blockGlobalShortcuts(bool block) override; QString variantKey() const override; std::optional resolveNamedAction(const QString &id) const override; + bool supportsActionRing() const override { +#ifdef LOGITUNE_LAYER_SHELL + return true; +#else + return false; +#endif + } + void requestActionRingPlacement(int requestId, const QSize &windowSize, + const QPoint &anchor) override; + void cancelActionRingPlacement(int requestId) override; + + static QPoint actionRingPlacement(const QPoint &cursor, const QRect &available, + const QSize &windowSize, const QPoint &anchor); /// Test seam: point to a registry the resolver should query. /// Takes non-owning pointer; the registry must outlive this object. @@ -31,10 +49,12 @@ public slots: // Called by KWin focus watcher script via D-Bus void focusChanged(const QString &resourceClass, const QString &title, const QString &desktopFileName = QString()); + void actionRingCursorPosition(int requestId, int x, int y); private slots: void onActiveWindowChanged(); void pollActiveWindow(); + void actionRingRequestFailed(); private: QDBusInterface *m_kwin = nullptr; @@ -42,6 +62,13 @@ private slots: QString m_lastWmClass; bool m_available = false; const ActionPresetRegistry *m_registry = nullptr; + QTimer m_actionRingTimeout; + QString m_actionRingObjectPath; + QString m_actionRingScriptName; + int m_actionRingRequest = -1; + QSize m_actionRingSize; + QPoint m_actionRingAnchor; + bool m_actionRingObjectRegistered = false; }; } // namespace logitune diff --git a/src/core/interfaces/IDesktopIntegration.cpp b/src/core/interfaces/IDesktopIntegration.cpp index 238efdc2..ae8b8791 100644 --- a/src/core/interfaces/IDesktopIntegration.cpp +++ b/src/core/interfaces/IDesktopIntegration.cpp @@ -1,2 +1,16 @@ // MOC requires a .cpp for abstract QObject interfaces with signals. #include "interfaces/IDesktopIntegration.h" + +namespace logitune { + +void IDesktopIntegration::requestActionRingPlacement(int requestId, const QSize &, + const QPoint &) +{ + emit actionRingPlacementFailed(requestId); +} + +void IDesktopIntegration::cancelActionRingPlacement(int) +{ +} + +} // namespace logitune diff --git a/src/core/interfaces/IDesktopIntegration.h b/src/core/interfaces/IDesktopIntegration.h index bc3022f6..b76638aa 100644 --- a/src/core/interfaces/IDesktopIntegration.h +++ b/src/core/interfaces/IDesktopIntegration.h @@ -1,6 +1,9 @@ #pragma once #include "ButtonAction.h" #include +#include +#include +#include #include #include #include @@ -37,8 +40,21 @@ class IDesktopIntegration : public QObject { /// Each entry is a QVariantMap with keys "wmClass" and "title". virtual QVariantList runningApplications() const = 0; + /// Whether this backend can reliably place a pointer-driven overlay. + virtual bool supportsActionRing() const { return false; } + + /// Resolve the current pointer position and a clamped overlay position. + /// Backends that do not support the action ring report failure. + virtual void requestActionRingPlacement(int requestId, const QSize &windowSize, + const QPoint &anchor); + virtual void cancelActionRingPlacement(int requestId); + signals: void activeWindowChanged(const QString &wmClass, const QString &title); + void actionRingPlacementReady(int requestId, const QPoint &position, + const QRect &screenGeometry, + const QPoint &cursorPosition); + void actionRingPlacementFailed(int requestId); }; } // namespace logitune diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 63c3e054..67422ee1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,8 @@ add_executable(logitune-tests test_receiver_info.cpp test_button_features.cpp test_profile_engine.cpp + test_radial_menu.cpp + test_radial_menu_controller.cpp test_device_discovery.cpp test_action_executor.cpp test_action_filter_model.cpp diff --git a/tests/mocks/MockDesktop.h b/tests/mocks/MockDesktop.h index ba01231a..83b2290f 100644 --- a/tests/mocks/MockDesktop.h +++ b/tests/mocks/MockDesktop.h @@ -40,6 +40,20 @@ class MockDesktop : public logitune::IDesktopIntegration { return m_runningApps; } + bool supportsActionRing() const override { return m_supportsActionRing; } + void requestActionRingPlacement(int requestId, const QSize &windowSize, + const QPoint &anchor) override { + m_lastPlacementRequest = requestId; + m_lastWindowSize = windowSize; + m_lastAnchor = anchor; + if (m_autoCompletePlacement) + emit actionRingPlacementReady(requestId, QPoint(100, 120), + QRect(0, 0, 1920, 1080), QPoint(290, 285)); + } + void cancelActionRingPlacement(int requestId) override { + m_lastCancelledRequest = requestId; + } + // --- Test helpers --- void simulateFocus(const QString &wmClass, const QString &title) { @@ -60,12 +74,22 @@ class MockDesktop : public logitune::IDesktopIntegration { void clearScriptedResolves() { m_scripted.clear(); } int blockCount() const { return m_blockCount; } + void setSupportsActionRing(bool supported) { m_supportsActionRing = supported; } + void setAutoCompletePlacement(bool enabled) { m_autoCompletePlacement = enabled; } + int lastPlacementRequest() const { return m_lastPlacementRequest; } + int lastCancelledRequest() const { return m_lastCancelledRequest; } private: QVariantList m_runningApps; int m_blockCount = 0; QString m_variantKey = QStringLiteral("mock"); QHash m_scripted; + bool m_supportsActionRing = false; + bool m_autoCompletePlacement = true; + int m_lastPlacementRequest = -1; + int m_lastCancelledRequest = -1; + QSize m_lastWindowSize; + QPoint m_lastAnchor; }; } // namespace logitune::test diff --git a/tests/services/test_button_action_dispatcher.cpp b/tests/services/test_button_action_dispatcher.cpp index d8871989..40e3c140 100644 --- a/tests/services/test_button_action_dispatcher.cpp +++ b/tests/services/test_button_action_dispatcher.cpp @@ -247,6 +247,22 @@ TEST_F(ButtonActionDispatcherFixture, KeystrokeActionInjectsViaExecutor) { EXPECT_EQ(m_injector->lastArg("injectKeystroke"), QStringLiteral("Ctrl+C")); } +TEST_F(ButtonActionDispatcherFixture, RadialMenuReleaseLeavesOverlayOpenAndActivatesItem) { + attachMockSession(); + m_desktop->setSupportsActionRing(true); + setProfileButton(hwProfile(), 3, {ButtonAction::RadialMenu, {}}); + + m_dispatcher->onDivertedButtonPressed(m_physical, kBackCid, true); + ASSERT_TRUE(m_dispatcher->radialMenu()->visible()); + + m_dispatcher->onDivertedButtonPressed(m_physical, kBackCid, false); + EXPECT_TRUE(m_dispatcher->radialMenu()->visible()); + + m_dispatcher->radialMenu()->activate(0); + EXPECT_FALSE(m_dispatcher->radialMenu()->visible()); + EXPECT_EQ(m_injector->lastArg("injectKeystroke"), QStringLiteral("Ctrl+C")); +} + TEST_F(ButtonActionDispatcherFixture, MediaActionInjectsViaExecutor) { attachMockSession(); setProfileButton(hwProfile(), 4, diff --git a/tests/test_action_filter_model.cpp b/tests/test_action_filter_model.cpp index 81610f4b..69e1d5e7 100644 --- a/tests/test_action_filter_model.cpp +++ b/tests/test_action_filter_model.cpp @@ -63,12 +63,31 @@ PhysicalDevice* attachMockDevice(DeviceModel &model, TEST(ActionFilterModel, EmptyDeviceModelShowsFullList) { ActionModel source; DeviceModel dm; - ActionFilterModel proxy(&dm); + MockDesktop desktop; + desktop.setSupportsActionRing(true); + ActionFilterModel proxy(&dm, &desktop); proxy.setSourceModel(&source); EXPECT_EQ(proxy.rowCount(), source.rowCount()); } +TEST(ActionFilterModel, ActionRingFollowsDesktopCapability) { + ActionModel source; + DeviceModel dm; + MockDesktop desktop; + ActionFilterModel proxy(&dm, &desktop); + proxy.setSourceModel(&source); + + EXPECT_EQ(proxyCountByName(proxy, QStringLiteral("Action ring")), 0); + + desktop.setSupportsActionRing(true); + proxy.invalidate(); + EXPECT_EQ(proxyCountByName(proxy, QStringLiteral("Action ring")), 1); + + proxy.setGestureMode(true); + EXPECT_EQ(proxyCountByName(proxy, QStringLiteral("Action ring")), 0); +} + TEST(ActionFilterModel, FilterHidesUnsupportedActions) { ActionModel source; DeviceModel dm; diff --git a/tests/test_action_model.cpp b/tests/test_action_model.cpp index 31ec867e..4d85485f 100644 --- a/tests/test_action_model.cpp +++ b/tests/test_action_model.cpp @@ -119,6 +119,14 @@ TEST_F(ActionModelTest, ButtonActionToNameGestureTrigger) { EXPECT_EQ(model.buttonActionToName(ba), QStringLiteral("Gestures")); } +TEST_F(ActionModelTest, RadialMenuRoundTripsThroughPickerEntry) { + EXPECT_EQ(model.buttonActionToName({ButtonAction::RadialMenu, {}}), + QStringLiteral("Action ring")); + EXPECT_EQ(model.buttonEntryToAction(QStringLiteral("radial-menu"), + QStringLiteral("Action ring")).type, + ButtonAction::RadialMenu); +} + TEST_F(ActionModelTest, ButtonActionToNameForKeystrokeKnownPayload) { // Copy has payload "Ctrl+C" — lookup should resolve back to "Copy". ButtonAction ba{ButtonAction::Keystroke, QStringLiteral("Ctrl+C")}; diff --git a/tests/test_button_action.cpp b/tests/test_button_action.cpp index 0068a2fb..3353b24a 100644 --- a/tests/test_button_action.cpp +++ b/tests/test_button_action.cpp @@ -38,6 +38,13 @@ TEST(ButtonAction, ParseDpiCycleKeyword) { EXPECT_TRUE(a.payload.isEmpty()); } +TEST(ButtonAction, ParseRadialMenuKeyword) { + const ButtonAction action = ButtonAction::parse(QStringLiteral("radial-menu")); + EXPECT_EQ(action.type, ButtonAction::RadialMenu); + EXPECT_TRUE(action.payload.isEmpty()); + EXPECT_EQ(action.serialize(), QStringLiteral("radial-menu")); +} + TEST(ButtonAction, SerializeDpiCycleType) { ButtonAction a{ButtonAction::DpiCycle, {}}; EXPECT_EQ(a.serialize(), "dpi-cycle"); diff --git a/tests/test_device_routing.cpp b/tests/test_device_routing.cpp index 77d62a99..d5d3cb73 100644 --- a/tests/test_device_routing.cpp +++ b/tests/test_device_routing.cpp @@ -151,6 +151,25 @@ TEST_F(DeviceRoutingTest, GestureUsesTheGestureMapOfTheMouseItCameFrom) { EXPECT_EQ(m_injector->lastArg(QStringLiteral("injectKeystroke")), QStringLiteral("Alt+Tab")); } +TEST_F(DeviceRoutingTest, ActionRingUsesTheProfileOfItsOriginatingMouse) { + PhysicalDevice *b = addSecondMouseAndSelectFirst(); + m_desktop->setSupportsActionRing(true); + hwProfile(kSerialA).buttons[3] = {ButtonAction::RadialMenu, {}}; + hwProfile(kSerialA).radialMenu.items[0].action = + {ButtonAction::Keystroke, QStringLiteral("Alt+Left")}; + hwProfile(kSerialB).buttons[3] = {ButtonAction::RadialMenu, {}}; + hwProfile(kSerialB).radialMenu.items[0].action = + {ButtonAction::Keystroke, QStringLiteral("Ctrl+Z")}; + m_injector->clear(); + + emit b->divertedButtonPressed(kBackCid, true); + ASSERT_TRUE(m_ctrl->radialMenu()->visible()); + m_ctrl->radialMenu()->activate(0); + + EXPECT_EQ(m_injector->lastArg(QStringLiteral("injectKeystroke")), + QStringLiteral("Ctrl+Z")); +} + TEST_F(DeviceRoutingTest, GestureOnOneMouseDoesNotLeakIntoTheOther) { PhysicalDevice *b = addSecondMouseAndSelectFirst(); hwProfile(kSerialA).buttons[5] = {ButtonAction::GestureTrigger, {}}; diff --git a/tests/test_radial_menu.cpp b/tests/test_radial_menu.cpp new file mode 100644 index 00000000..8199295c --- /dev/null +++ b/tests/test_radial_menu.cpp @@ -0,0 +1,98 @@ +#include + +#include +#include + +#include "ProfileEngine.h" +#include "RadialMenuConfig.h" + +using namespace logitune; + +TEST(RadialMenuConfig, DefaultsAreValid) +{ + const RadialMenuConfig config; + EXPECT_TRUE(config.isValid()); + EXPECT_GE(config.items.size(), 2); + EXPECT_LE(config.items.size(), 8); +} + +TEST(RadialMenuConfig, RejectsRecursiveActions) +{ + RadialMenuConfig config; + config.items[0].action = {ButtonAction::RadialMenu, {}}; + EXPECT_FALSE(config.isValid()); + + config.items[0].action = {ButtonAction::GestureTrigger, {}}; + EXPECT_FALSE(config.isValid()); +} + +TEST(RadialMenuConfig, ProfileRoundTripUsesIniKeys) +{ + QTemporaryDir temp; + ASSERT_TRUE(temp.isValid()); + const QString path = temp.filePath(QStringLiteral("profile.conf")); + + Profile profile; + profile.name = QStringLiteral("default"); + profile.radialMenu.items = { + {QStringLiteral("Back"), {ButtonAction::Keystroke, QStringLiteral("Alt+Left")}}, + {QStringLiteral("Calculator"), {ButtonAction::PresetRef, QStringLiteral("calculator")}}, + }; + ProfileEngine::saveProfile(path, profile); + + QSettings raw(path, QSettings::IniFormat); + EXPECT_EQ(raw.value(QStringLiteral("ActionRing/count")).toInt(), 2); + EXPECT_EQ(raw.value(QStringLiteral("ActionRing/Items/0/label")).toString(), + QStringLiteral("Back")); + EXPECT_EQ(raw.value(QStringLiteral("ActionRing/Items/1/action")).toString(), + QStringLiteral("preset:calculator")); + + const Profile loaded = ProfileEngine::loadProfile(path); + ASSERT_EQ(loaded.radialMenu.items.size(), 2); + EXPECT_EQ(loaded.radialMenu.items.at(0), profile.radialMenu.items.at(0)); + EXPECT_EQ(loaded.radialMenu.items.at(1), profile.radialMenu.items.at(1)); +} + +TEST(RadialMenuConfig, InvalidEntryDoesNotResetValidEntries) +{ + QTemporaryDir temp; + ASSERT_TRUE(temp.isValid()); + const QString path = temp.filePath(QStringLiteral("profile.conf")); + QSettings settings(path, QSettings::IniFormat); + settings.setValue(QStringLiteral("ActionRing/count"), 3); + settings.setValue(QStringLiteral("ActionRing/Items/0/label"), QStringLiteral("Back")); + settings.setValue(QStringLiteral("ActionRing/Items/0/action"), QStringLiteral("keystroke:Alt+Left")); + settings.setValue(QStringLiteral("ActionRing/Items/1/label"), QStringLiteral("Broken")); + settings.setValue(QStringLiteral("ActionRing/Items/1/action"), QStringLiteral("radial-menu")); + settings.setValue(QStringLiteral("ActionRing/Items/2/label"), QStringLiteral("Mute")); + settings.setValue(QStringLiteral("ActionRing/Items/2/action"), QStringLiteral("media:Mute")); + settings.sync(); + + const RadialMenuConfig loaded = RadialMenuConfig::load(settings); + ASSERT_EQ(loaded.items.size(), 3); + EXPECT_EQ(loaded.items.at(0).label, QStringLiteral("Back")); + EXPECT_EQ(loaded.items.at(0).action.serialize(), QStringLiteral("keystroke:Alt+Left")); + EXPECT_EQ(loaded.items.at(1).label, QStringLiteral("Broken")); + EXPECT_NE(loaded.items.at(1).action.type, ButtonAction::RadialMenu); + EXPECT_EQ(loaded.items.at(2).label, QStringLiteral("Mute")); + EXPECT_EQ(loaded.items.at(2).action.serialize(), QStringLiteral("media:Mute")); + EXPECT_TRUE(loaded.isValid()); +} + +TEST(RadialMenuConfig, SaveRepairsOnlyInvalidEntry) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + QSettings settings(dir.filePath(QStringLiteral("profile.conf")), QSettings::IniFormat); + RadialMenuConfig config; + config.items[0] = {QStringLiteral("Back"), + {ButtonAction::Keystroke, QStringLiteral("Alt+Left")}}; + config.items[1] = {QStringLiteral("Recursive"), {ButtonAction::RadialMenu, {}}}; + + config.save(settings); + settings.sync(); + const RadialMenuConfig loaded = RadialMenuConfig::load(settings); + + EXPECT_EQ(loaded.items.at(0), config.items.at(0)); + EXPECT_NE(loaded.items.at(1).action.type, ButtonAction::RadialMenu); +} diff --git a/tests/test_radial_menu_controller.cpp b/tests/test_radial_menu_controller.cpp new file mode 100644 index 00000000..b283517b --- /dev/null +++ b/tests/test_radial_menu_controller.cpp @@ -0,0 +1,75 @@ +#include + +#include "desktop/KDeDesktop.h" +#include "mocks/MockDesktop.h" +#include "services/RadialMenuController.h" + +using namespace logitune; +using namespace logitune::test; + +TEST(RadialMenuController, UnsupportedDesktopNeverOpens) +{ + MockDesktop desktop; + RadialMenuController controller(&desktop); + + controller.begin(RadialMenuConfig::defaults()); + + EXPECT_FALSE(controller.isActive()); + EXPECT_FALSE(controller.visible()); + EXPECT_EQ(desktop.lastPlacementRequest(), -1); +} + +TEST(RadialMenuController, OpensOnlyAfterDesktopPlacementCompletes) +{ + MockDesktop desktop; + desktop.setSupportsActionRing(true); + desktop.setAutoCompletePlacement(false); + RadialMenuController controller(&desktop); + + controller.begin(RadialMenuConfig::defaults()); + ASSERT_TRUE(controller.isActive()); + EXPECT_FALSE(controller.visible()); + const int request = desktop.lastPlacementRequest(); + + emit desktop.actionRingPlacementReady(request, QPoint(10, 20), + QRect(0, 0, 800, 600), QPoint(200, 185)); + EXPECT_TRUE(controller.visible()); + EXPECT_EQ(controller.overlayPosition(), QPoint(10, 20)); + EXPECT_EQ(controller.cursorPosition(), QPoint(200, 185)); +} + +TEST(RadialMenuController, ActivationClosesBeforeEmittingAction) +{ + MockDesktop desktop; + desktop.setSupportsActionRing(true); + RadialMenuController controller(&desktop); + ButtonAction fired; + bool visibleWhileEmitting = true; + QObject::connect(&controller, &RadialMenuController::triggered, + [&controller, &fired, &visibleWhileEmitting](const ButtonAction &action) { + fired = action; + visibleWhileEmitting = controller.visible(); + }); + + controller.begin(RadialMenuConfig::defaults()); + ASSERT_TRUE(controller.visible()); + controller.activate(0); + + EXPECT_FALSE(visibleWhileEmitting); + EXPECT_EQ(fired.type, ButtonAction::Keystroke); + EXPECT_EQ(fired.payload, QStringLiteral("Ctrl+C")); +} + +TEST(KDeDesktop, ActionRingPlacementClampsToAvailableGeometry) +{ + const QRect available(100, 50, 1000, 700); + const QSize size(380, 380); + const QPoint anchor(190, 165); + + EXPECT_EQ(KDeDesktop::actionRingPlacement(QPoint(150, 80), available, size, anchor), + QPoint(100, 50)); + EXPECT_EQ(KDeDesktop::actionRingPlacement(QPoint(1090, 740), available, size, anchor), + QPoint(720, 370)); + EXPECT_EQ(KDeDesktop::actionRingPlacement(QPoint(600, 400), available, size, anchor), + QPoint(410, 235)); +}