From 45546d0c29fdd236f108a4d9897012fc1c8200e8 Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Tue, 3 Nov 2026 22:14:39 +0000 Subject: [PATCH 1/7] fix(app): show the running window on a second launch A second launch used to fail the instance lock and exit silently while the first instance sat hidden. It now asks the running instance to show its window and exits 0. Without a tray, closing the window quits instead of hiding. --- src/app/CMakeLists.txt | 3 +- src/app/SingleInstance.cpp | 60 ++++++++++++++++++++++++++++++++++ src/app/SingleInstance.h | 29 ++++++++++++++++ src/app/main.cpp | 39 +++++++++++++--------- src/app/qml/Main.qml | 6 ++-- tests/CMakeLists.txt | 1 + tests/qml/tst_Main.qml | 28 ++++++++++++++++ tests/qml/tst_qml_main.cpp | 9 +++++ tests/test_single_instance.cpp | 23 +++++++++++++ 9 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 src/app/SingleInstance.cpp create mode 100644 src/app/SingleInstance.h create mode 100644 tests/qml/tst_Main.qml create mode 100644 tests/test_single_instance.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 397dba0f..feaf1908 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(logitune-app-lib STATIC dialogs/GitHubIssueBuilder.cpp dialogs/CrashReportDialog.cpp TrayManager.cpp + SingleInstance.cpp ) target_include_directories(logitune-app-lib PUBLIC @@ -23,7 +24,7 @@ target_include_directories(logitune-app-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/services ) -target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets) +target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets Qt6::Network) target_compile_definitions(logitune-app-lib PUBLIC PROJECT_VERSION="${PROJECT_VERSION}" PROJECT_VERSION_FULL="${LOGITUNE_DISPLAY_VERSION}" diff --git a/src/app/SingleInstance.cpp b/src/app/SingleInstance.cpp new file mode 100644 index 00000000..4eb578b2 --- /dev/null +++ b/src/app/SingleInstance.cpp @@ -0,0 +1,60 @@ +#include "SingleInstance.h" + +#include +#include +#include + +namespace logitune { + +SingleInstance::SingleInstance(const QString &lockPath, const QString &serverName) + : m_lockFile(lockPath), m_serverName(serverName) +{ + m_server.setSocketOptions(QLocalServer::UserAccessOption); + connect(&m_server, &QLocalServer::newConnection, this, [this]() { + while (auto *socket = m_server.nextPendingConnection()) { + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); + auto receive = [this, socket]() { + if (socket->canReadLine()) { + if (socket->readLine() == "show\n") + emit showRequested(); + socket->disconnectFromServer(); + } else if (socket->bytesAvailable() > 64) { + socket->disconnectFromServer(); + } + }; + connect(socket, &QLocalSocket::readyRead, this, receive); + receive(); + } + }); +} + +std::optional SingleInstance::start() +{ + if (m_lockFile.tryLock(100)) { + // Only the lock owner may remove a socket left by a crashed instance. + QLocalServer::removeServer(m_serverName); + if (!m_server.listen(m_serverName)) + return 1; + return std::nullopt; + } + + // A concurrent first launch may own the lock before its socket is ready. + QElapsedTimer timeout; + timeout.start(); + do { + QLocalSocket socket; + socket.connectToServer(m_serverName); + if (socket.waitForConnected(100)) { + if (socket.write("show\n") != 5) + return 1; + if (socket.bytesToWrite() && !socket.waitForBytesWritten(1000)) + return 1; + return 0; + } + QThread::msleep(50); + } while (timeout.elapsed() < 2000); + + return 1; +} + +} // namespace logitune diff --git a/src/app/SingleInstance.h b/src/app/SingleInstance.h new file mode 100644 index 00000000..6744776a --- /dev/null +++ b/src/app/SingleInstance.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace logitune { + +class SingleInstance : public QObject +{ + Q_OBJECT +public: + SingleInstance(const QString &lockPath, const QString &serverName); + + // An exit code means this launch must not initialize the devices or UI. + std::optional start(); + +signals: + void showRequested(); + +private: + QLockFile m_lockFile; + QLocalServer m_server; + QString m_serverName; +}; + +} // namespace logitune diff --git a/src/app/main.cpp b/src/app/main.cpp index 1a9b3069..d0a3a323 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -25,6 +24,18 @@ #include "logging/CrashHandler.h" #include "dialogs/CrashReportDialog.h" #include "TrayManager.h" +#include "SingleInstance.h" + +static void showMainWindow(QQmlApplicationEngine &engine) +{ + for (auto *obj : engine.rootObjects()) { + if (auto *window = qobject_cast(obj)) { + window->show(); + window->raise(); + window->requestActivate(); + } + } +} int main(int argc, char *argv[]) { @@ -90,12 +101,13 @@ int main(int argc, char *argv[]) const bool editMode = parser.isSet(editOption); const bool startMinimized = parser.isSet(minimizedOption); - // Single-instance guard — prevent two instances fighting over the device - QLockFile lockFile(QStandardPaths::writableLocation(QStandardPaths::TempLocation) - + "/logitune.lock"); - if (!lockFile.tryLock(100)) { - qCritical() << "Another instance of Logitune is already running"; - return 1; + const QString runtimePath = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + logitune::SingleInstance singleInstance(runtimePath + "/logitune.lock", + runtimePath + "/logitune"); + if (const auto exitCode = singleInstance.start()) { + if (*exitCode != 0) + qCritical() << "Could not start or contact the primary Logitune instance"; + return *exitCode; } // Initialize logging — enabled by default, user can disable in Settings @@ -176,6 +188,10 @@ int main(int argc, char *argv[]) // QML engine qCInfo(lcApp) << "Creating QML engine..."; QQmlApplicationEngine engine; + const bool trayVisible = QSystemTrayIcon::isSystemTrayAvailable(); + engine.rootContext()->setContextProperty("systemTrayAvailable", trayVisible); + QObject::connect(&singleInstance, &logitune::SingleInstance::showRequested, + &engine, [&engine]() { showMainWindow(engine); }); qCInfo(lcApp) << "QML engine created"; class IconProvider : public QQuickImageProvider { @@ -250,13 +266,7 @@ int main(int argc, char *argv[]) // System tray logitune::TrayManager tray(controller.deviceModel()); QObject::connect(&tray, &logitune::TrayManager::showWindowRequested, [&engine]() { - for (auto *obj : engine.rootObjects()) { - if (auto *window = qobject_cast(obj)) { - window->show(); - window->raise(); - window->requestActivate(); - } - } + showMainWindow(engine); }); QObject::connect(tray.quitAction(), &QAction::triggered, &app, &QApplication::quit); tray.show(); @@ -267,7 +277,6 @@ int main(int argc, char *argv[]) // on GNOME Wayland without an AppIndicator extension. Using the wrong // check stranded users with no way to reach the app after --minimized // autostart on a session without a tray. - const bool trayVisible = QSystemTrayIcon::isSystemTrayAvailable(); if (!trayVisible) { // Without a tray icon (e.g. GNOME with no AppIndicator extension) // there is no way to re-open the window or quit from a tray menu, so diff --git a/src/app/qml/Main.qml b/src/app/qml/Main.qml index c8f2f41e..4feb52d3 100644 --- a/src/app/qml/Main.qml +++ b/src/app/qml/Main.qml @@ -75,8 +75,10 @@ ApplicationWindow { flags: Qt.FramelessWindowHint | Qt.Window onClosing: function(close) { - close.accepted = false - hide() + if (systemTrayAvailable) { + close.accepted = false + hide() + } } // ── Custom title bar ──────────────────────────────────────────────── diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4168b173..3de92df4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,6 +4,7 @@ include(GoogleTest) add_executable(logitune-tests test_main.cpp test_smoke.cpp + test_single_instance.cpp test_transport.cpp test_feature_dispatcher.cpp test_capability_dispatch.cpp diff --git a/tests/qml/tst_Main.qml b/tests/qml/tst_Main.qml new file mode 100644 index 00000000..2091d7bd --- /dev/null +++ b/tests/qml/tst_Main.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtTest +import Logitune + +TestCase { + name: "MainClosing" + when: windowShown + + Component { + id: mainComponent + Main {} + } + + function test_close_data() { + return [ + { tag: "withoutTray", tray: false, accepted: true }, + { tag: "withTray", tray: true, accepted: false } + ] + } + + function test_close(data) { + TestSetup.setTrayAvailable(data.tray) + var main = createTemporaryObject(mainComponent, null) + verify(main !== null) + compare(main.close(), data.accepted) + compare(main.visible, false) + } +} diff --git a/tests/qml/tst_qml_main.cpp b/tests/qml/tst_qml_main.cpp index 926ff3f8..0a9e2577 100644 --- a/tests/qml/tst_qml_main.cpp +++ b/tests/qml/tst_qml_main.cpp @@ -18,6 +18,11 @@ class QmlTestSetup : public QObject public: QmlTestSetup() = default; + Q_INVOKABLE void setTrayAvailable(bool available) + { + m_engine->rootContext()->setContextProperty("systemTrayAvailable", available); + } + public slots: void applicationAvailable() { @@ -26,6 +31,9 @@ public slots: void qmlEngineAvailable(QQmlEngine *engine) { + m_engine = engine; + engine->rootContext()->setContextProperty("TestSetup", this); + setTrayAvailable(false); engine->addImportPath("qrc:/"); engine->rootContext()->setContextProperty("DeviceModel", &m_deviceModel); @@ -38,6 +46,7 @@ public slots: } private: + QQmlEngine *m_engine = nullptr; logitune::DeviceModel m_deviceModel; logitune::ButtonModel m_buttonModel; logitune::ActionModel m_actionModel; diff --git a/tests/test_single_instance.cpp b/tests/test_single_instance.cpp new file mode 100644 index 00000000..eb30f129 --- /dev/null +++ b/tests/test_single_instance.cpp @@ -0,0 +1,23 @@ +#include +#include +#include +#include + +#include "SingleInstance.h" + +TEST(SingleInstanceTest, SecondaryRequestsShowAndExitsSuccessfully) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString serverName = "logitune-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + logitune::SingleInstance primary(dir.filePath("instance.lock"), serverName); + ASSERT_FALSE(primary.start().has_value()); + QSignalSpy shown(&primary, &logitune::SingleInstance::showRequested); + + logitune::SingleInstance secondary(dir.filePath("instance.lock"), serverName); + const auto exitCode = secondary.start(); + ASSERT_TRUE(exitCode.has_value()); + EXPECT_EQ(*exitCode, 0); + ASSERT_TRUE(shown.wait(2000)); + EXPECT_EQ(shown.count(), 1); +} From bb7e09662cb26b920a6ec0c6890a9ea81db379da Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Tue, 3 Nov 2026 22:46:49 +0000 Subject: [PATCH 2/7] feat(app): keep running in the background when the window closes Closing the window destroys the UI and keeps devices, profiles, the tray and the single-instance server running, with or without a tray. Launching again rebuilds the window. --minimized starts without building it at all. Add --quit and a Quit button in Settings so the app can be ended without a tray. --- src/app/CMakeLists.txt | 1 + src/app/MainWindowController.cpp | 65 ++++++++++++++ src/app/MainWindowController.h | 24 +++++ src/app/SingleInstance.cpp | 14 ++- src/app/SingleInstance.h | 4 +- src/app/main.cpp | 101 +++++++-------------- src/app/qml/AppSettingsView.qml | 25 ++++++ src/app/qml/Main.qml | 12 +-- tests/CMakeLists.txt | 5 +- tests/qml/tst_AppSettingsView.qml | 43 +++++++++ tests/qml/tst_Main.qml | 12 +-- tests/qml/tst_qml_main.cpp | 9 -- tests/test_main_window.cpp | 145 ++++++++++++++++++++++++++++++ tests/test_single_instance.cpp | 56 ++++++++++++ 14 files changed, 414 insertions(+), 102 deletions(-) create mode 100644 src/app/MainWindowController.cpp create mode 100644 src/app/MainWindowController.h create mode 100644 tests/qml/tst_AppSettingsView.qml create mode 100644 tests/test_main_window.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index feaf1908..c2c483cd 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(logitune-app-lib STATIC dialogs/CrashReportDialog.cpp TrayManager.cpp SingleInstance.cpp + MainWindowController.cpp ) target_include_directories(logitune-app-lib PUBLIC diff --git a/src/app/MainWindowController.cpp b/src/app/MainWindowController.cpp new file mode 100644 index 00000000..39e93c06 --- /dev/null +++ b/src/app/MainWindowController.cpp @@ -0,0 +1,65 @@ +#include "MainWindowController.h" + +#include +#include + +#ifdef __GLIBC__ +#include +#endif + +namespace logitune { + +MainWindowController::MainWindowController(QQmlApplicationEngine &engine, bool dark) + : m_engine(engine), m_dark(dark) +{ +} + +bool MainWindowController::showMainWindow() +{ + if (!m_window) { + m_engine.load(QUrl(QStringLiteral("qrc:/Logitune/qml/Main.qml"))); + if (m_engine.rootObjects().isEmpty()) + return false; + m_window = qobject_cast(m_engine.rootObjects().last()); + if (!m_window) + return false; + + auto *window = m_window.data(); + connect(window, &QQuickWindow::closing, this, [this, window]() { + m_window = nullptr; + // Defer deletion until Qt has finished delivering the close event. + window->deleteLater(); + }); + connect(window, &QObject::destroyed, this, [this]() { + QTimer::singleShot(0, this, [this]() { + m_engine.collectGarbage(); + // Keep live singleton types valid while releasing unused UI components. + m_engine.trimComponentCache(); +#ifdef __GLIBC__ + // Return freed UI heap pages after deferred render cleanup has run. + QTimer::singleShot(100, this, [this]() { + if (!m_window) + malloc_trim(0); + }); +#endif + }); + }); + + if (!m_themeInitialized) { + QQmlExpression theme(QQmlEngine::contextForObject(window), window, + m_dark ? QStringLiteral("Theme.dark = true") + : QStringLiteral("Theme.dark = false")); + theme.evaluate(); + if (theme.hasError()) + qWarning() << "Theme expression error:" << theme.error().toString(); + m_themeInitialized = true; + } + } + + m_window->show(); + m_window->raise(); + m_window->requestActivate(); + return true; +} + +} // namespace logitune diff --git a/src/app/MainWindowController.h b/src/app/MainWindowController.h new file mode 100644 index 00000000..19988e54 --- /dev/null +++ b/src/app/MainWindowController.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +namespace logitune { + +class MainWindowController : public QObject +{ +public: + explicit MainWindowController(QQmlApplicationEngine &engine, bool dark = false); + + bool showMainWindow(); + +private: + QQmlApplicationEngine &m_engine; + QPointer m_window; + bool m_dark; + bool m_themeInitialized = false; +}; + +} // namespace logitune diff --git a/src/app/SingleInstance.cpp b/src/app/SingleInstance.cpp index 4eb578b2..1035f904 100644 --- a/src/app/SingleInstance.cpp +++ b/src/app/SingleInstance.cpp @@ -15,8 +15,11 @@ SingleInstance::SingleInstance(const QString &lockPath, const QString &serverNam connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); auto receive = [this, socket]() { if (socket->canReadLine()) { - if (socket->readLine() == "show\n") + const auto command = socket->readLine(); + if (command == "show\n") emit showRequested(); + else if (command == "quit\n") + emit quitRequested(); socket->disconnectFromServer(); } else if (socket->bytesAvailable() > 64) { socket->disconnectFromServer(); @@ -28,9 +31,13 @@ SingleInstance::SingleInstance(const QString &lockPath, const QString &serverNam }); } -std::optional SingleInstance::start() +std::optional SingleInstance::start(Command command) { if (m_lockFile.tryLock(100)) { + if (command == Command::Quit) { + m_lockFile.unlock(); + return 0; + } // Only the lock owner may remove a socket left by a crashed instance. QLocalServer::removeServer(m_serverName); if (!m_server.listen(m_serverName)) @@ -45,7 +52,8 @@ std::optional SingleInstance::start() QLocalSocket socket; socket.connectToServer(m_serverName); if (socket.waitForConnected(100)) { - if (socket.write("show\n") != 5) + const QByteArray message = command == Command::Quit ? "quit\n" : "show\n"; + if (socket.write(message) != message.size()) return 1; if (socket.bytesToWrite() && !socket.waitForBytesWritten(1000)) return 1; diff --git a/src/app/SingleInstance.h b/src/app/SingleInstance.h index 6744776a..eb3e210e 100644 --- a/src/app/SingleInstance.h +++ b/src/app/SingleInstance.h @@ -12,13 +12,15 @@ class SingleInstance : public QObject { Q_OBJECT public: + enum class Command { Show, Quit }; SingleInstance(const QString &lockPath, const QString &serverName); // An exit code means this launch must not initialize the devices or UI. - std::optional start(); + std::optional start(Command command = Command::Show); signals: void showRequested(); + void quitRequested(); private: QLockFile m_lockFile; diff --git a/src/app/main.cpp b/src/app/main.cpp index d0a3a323..60f6c226 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -25,17 +24,7 @@ #include "dialogs/CrashReportDialog.h" #include "TrayManager.h" #include "SingleInstance.h" - -static void showMainWindow(QQmlApplicationEngine &engine) -{ - for (auto *obj : engine.rootObjects()) { - if (auto *window = qobject_cast(obj)) { - window->show(); - window->raise(); - window->requestActivate(); - } - } -} +#include "MainWindowController.h" int main(int argc, char *argv[]) { @@ -68,7 +57,7 @@ int main(int argc, char *argv[]) app.setOrganizationName("Logitune"); app.setApplicationName("Logitune"); app.setApplicationVersion(QStringLiteral(PROJECT_VERSION_FULL)); - app.setQuitOnLastWindowClosed(false); // tray icon keeps app alive + app.setQuitOnLastWindowClosed(false); app.setWindowIcon(QIcon(":/Logitune/qml/assets/logitune-icon.svg")); // Command-line parsing @@ -93,9 +82,12 @@ int main(int argc, char *argv[]) parser.addOption(editOption); QCommandLineOption minimizedOption( QStringLiteral("minimized"), - QStringLiteral("Start hidden to the system tray. Intended for autostart " - "launchers; ignored if no system tray is available.")); + QStringLiteral("Start without a window. Launch again to show it.")); parser.addOption(minimizedOption); + QCommandLineOption quitOption( + QStringLiteral("quit"), + QStringLiteral("Quit the running instance without starting a new one.")); + parser.addOption(quitOption); parser.process(app); const bool simulateAll = parser.isSet(simulateAllOption); const bool editMode = parser.isSet(editOption); @@ -104,7 +96,11 @@ int main(int argc, char *argv[]) const QString runtimePath = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); logitune::SingleInstance singleInstance(runtimePath + "/logitune.lock", runtimePath + "/logitune"); - if (const auto exitCode = singleInstance.start()) { + QObject::connect(&singleInstance, &logitune::SingleInstance::quitRequested, + &app, &QCoreApplication::quit); + const auto command = parser.isSet(quitOption) ? logitune::SingleInstance::Command::Quit + : logitune::SingleInstance::Command::Show; + if (const auto exitCode = singleInstance.start(command)) { if (*exitCode != 0) qCritical() << "Could not start or contact the primary Logitune instance"; return *exitCode; @@ -188,10 +184,7 @@ int main(int argc, char *argv[]) // QML engine qCInfo(lcApp) << "Creating QML engine..."; QQmlApplicationEngine engine; - const bool trayVisible = QSystemTrayIcon::isSystemTrayAvailable(); - engine.rootContext()->setContextProperty("systemTrayAvailable", trayVisible); - QObject::connect(&singleInstance, &logitune::SingleInstance::showRequested, - &engine, [&engine]() { showMainWindow(engine); }); + QObject::connect(&engine, &QQmlApplicationEngine::quit, &app, &QCoreApplication::quit); qCInfo(lcApp) << "QML engine created"; class IconProvider : public QQuickImageProvider { @@ -240,63 +233,31 @@ int main(int argc, char *argv[]) #endif } - qCInfo(lcApp) << "Loading QML..."; - engine.load(QUrl(QStringLiteral("qrc:/Logitune/qml/Main.qml"))); - qCInfo(lcApp) << "QML loaded, root objects:" << engine.rootObjects().size(); - - if (engine.rootObjects().isEmpty()) { - qCCritical(lcApp) << "QML failed to load — no root objects"; - return -1; - } - - // Set theme via the root QML object which has access to the Theme singleton - qCInfo(lcApp) << "Setting theme dark:" << isDark; - if (!engine.rootObjects().isEmpty()) { - QObject *root = engine.rootObjects().first(); - QQmlExpression expr(QQmlEngine::contextForObject(root), root, - isDark ? QStringLiteral("Theme.dark = true") - : QStringLiteral("Theme.dark = false")); - QVariant result = expr.evaluate(); - if (expr.hasError()) - qCWarning(lcApp) << "Theme expression error:" << expr.error().toString(); - else - qCInfo(lcApp) << "Theme.dark applied:" << isDark; - } + logitune::MainWindowController windows(engine, isDark); + auto showMainWindow = [&windows]() { + if (!windows.showMainWindow()) + qCCritical(lcApp) << "QML failed to load — no main window"; + }; + QObject::connect(&singleInstance, &logitune::SingleInstance::showRequested, + &windows, showMainWindow); // System tray logitune::TrayManager tray(controller.deviceModel()); - QObject::connect(&tray, &logitune::TrayManager::showWindowRequested, [&engine]() { - showMainWindow(engine); - }); + QObject::connect(&tray, &logitune::TrayManager::showWindowRequested, + &windows, showMainWindow); QObject::connect(tray.quitAction(), &QAction::triggered, &app, &QApplication::quit); tray.show(); - // QSystemTrayIcon::isVisible() returns true as soon as show() is called, - // even when no StatusNotifier host exists to actually render the icon. - // isSystemTrayAvailable() is the real capability check: it returns false - // on GNOME Wayland without an AppIndicator extension. Using the wrong - // check stranded users with no way to reach the app after --minimized - // autostart on a session without a tray. - if (!trayVisible) { - // Without a tray icon (e.g. GNOME with no AppIndicator extension) - // there is no way to re-open the window or quit from a tray menu, so - // closing the window must terminate the process or the user gets - // stranded with an invisible running app. - app.setQuitOnLastWindowClosed(true); - qCInfo(lcApp) << "Tray unavailable -- closing the last window will " - "quit the app"; - } - if (startMinimized && trayVisible) { - for (QObject *obj : engine.rootObjects()) { - if (auto *window = qobject_cast(obj)) - window->hide(); - } - qCInfo(lcApp) << "Startup: minimized to tray"; - } else if (startMinimized && !trayVisible) { - qCInfo(lcApp) << "Startup: --minimized requested but tray is not " - "visible, showing window"; + if (!QSystemTrayIcon::isSystemTrayAvailable()) + qCInfo(lcApp) << "Tray unavailable -- launch again to show the window; use --quit to exit"; + + if (startMinimized) { + qCInfo(lcApp) << "Startup: running without a window"; } else { - qCDebug(lcApp) << "Startup: showing window"; + if (!windows.showMainWindow()) { + qCCritical(lcApp) << "QML failed to load — no main window"; + return -1; + } } qCInfo(lcApp) << "Startup complete"; diff --git a/src/app/qml/AppSettingsView.qml b/src/app/qml/AppSettingsView.qml index 66b1806d..60791b43 100644 --- a/src/app/qml/AppSettingsView.qml +++ b/src/app/qml/AppSettingsView.qml @@ -5,6 +5,7 @@ import Logitune Item { id: root + signal quitRequested() RowLayout { id: header @@ -42,6 +43,30 @@ Item { } Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: 140 + Layout.preferredHeight: 40 + radius: 4 + color: quitHover.hovered ? Theme.hoverBg : "transparent" + border.color: Theme.border; border.width: 1 + + Text { + anchors.centerIn: parent + text: "Quit Logitune" + font.pixelSize: 13; color: Theme.textSecondary + } + + HoverHandler { id: quitHover } + MouseArea { + objectName: "quitButton" + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.quitRequested() + } + } + + Item { width: 16 } } Column { diff --git a/src/app/qml/Main.qml b/src/app/qml/Main.qml index 4feb52d3..1994942e 100644 --- a/src/app/qml/Main.qml +++ b/src/app/qml/Main.qml @@ -74,13 +74,6 @@ ApplicationWindow { color: Theme.background flags: Qt.FramelessWindowHint | Qt.Window - onClosing: function(close) { - if (systemTrayAvailable) { - close.accepted = false - hide() - } - } - // ── Custom title bar ──────────────────────────────────────────────── Rectangle { id: titleBar @@ -270,7 +263,10 @@ ApplicationWindow { } } Component { id: deviceViewComponent; DeviceView {} } - Component { id: appSettingsComponent; AppSettingsView {} } + Component { + id: appSettingsComponent + AppSettingsView { onQuitRequested: Qt.quit() } + } // Permission error overlay Rectangle { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3de92df4..901f09cf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(logitune-tests test_main.cpp test_smoke.cpp test_single_instance.cpp + test_main_window.cpp test_transport.cpp test_feature_dispatcher.cpp test_capability_dispatch.cpp @@ -60,7 +61,9 @@ add_executable(logitune-tests ) target_include_directories(logitune-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_definitions(logitune-tests PRIVATE SOURCE_ROOT="${CMAKE_SOURCE_DIR}") -target_link_libraries(logitune-tests PRIVATE logitune-core logitune-app-lib Qt6::Test Qt6::Widgets GTest::gtest) +target_compile_definitions(logitune-tests PRIVATE LOGITUNE_BINARY="$") +add_dependencies(logitune-tests logitune) +target_link_libraries(logitune-tests PRIVATE logitune-core logitune-app-lib logitune-app-libplugin Qt6::Test Qt6::Widgets GTest::gtest) file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/logitune) file(CREATE_LINK ${CMAKE_SOURCE_DIR}/devices ${CMAKE_BINARY_DIR}/logitune/devices SYMBOLIC) diff --git a/tests/qml/tst_AppSettingsView.qml b/tests/qml/tst_AppSettingsView.qml new file mode 100644 index 00000000..d1611cfa --- /dev/null +++ b/tests/qml/tst_AppSettingsView.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import Logitune + +Item { + id: container + width: 960 + height: 900 + + AppSettingsView { + id: settings + anchors.fill: parent + } + + SignalSpy { + id: quitSpy + signalName: "quitRequested" + } + + TestCase { + name: "AppSettingsQuit" + when: windowShown + + function test_quitButtonRequestsQuit() { + var button = findChild(settings, "quitButton") + verify(button !== null, "Settings must offer an explicit Quit control") + quitSpy.target = settings + verify(quitSpy.valid) + mouseClick(button) + compare(quitSpy.count, 1) + } + + function test_quitAccessibleAtMinimumWindowHeight() { + container.height = 480 + var button = findChild(settings, "quitButton") + verify(button !== null) + var position = button.mapToItem(container, 0, 0) + verify(position.y >= 0 && position.y + button.height <= container.height, + "Quit must remain reachable in a small window") + container.height = 900 + } + } +} diff --git a/tests/qml/tst_Main.qml b/tests/qml/tst_Main.qml index 2091d7bd..9bb3609a 100644 --- a/tests/qml/tst_Main.qml +++ b/tests/qml/tst_Main.qml @@ -11,18 +11,10 @@ TestCase { Main {} } - function test_close_data() { - return [ - { tag: "withoutTray", tray: false, accepted: true }, - { tag: "withTray", tray: true, accepted: false } - ] - } - - function test_close(data) { - TestSetup.setTrayAvailable(data.tray) + function test_closeAlwaysAccepted() { var main = createTemporaryObject(mainComponent, null) verify(main !== null) - compare(main.close(), data.accepted) + compare(main.close(), true) compare(main.visible, false) } } diff --git a/tests/qml/tst_qml_main.cpp b/tests/qml/tst_qml_main.cpp index 0a9e2577..926ff3f8 100644 --- a/tests/qml/tst_qml_main.cpp +++ b/tests/qml/tst_qml_main.cpp @@ -18,11 +18,6 @@ class QmlTestSetup : public QObject public: QmlTestSetup() = default; - Q_INVOKABLE void setTrayAvailable(bool available) - { - m_engine->rootContext()->setContextProperty("systemTrayAvailable", available); - } - public slots: void applicationAvailable() { @@ -31,9 +26,6 @@ public slots: void qmlEngineAvailable(QQmlEngine *engine) { - m_engine = engine; - engine->rootContext()->setContextProperty("TestSetup", this); - setTrayAvailable(false); engine->addImportPath("qrc:/"); engine->rootContext()->setContextProperty("DeviceModel", &m_deviceModel); @@ -46,7 +38,6 @@ public slots: } private: - QQmlEngine *m_engine = nullptr; logitune::DeviceModel m_deviceModel; logitune::ButtonModel m_buttonModel; logitune::ActionModel m_actionModel; diff --git a/tests/test_main_window.cpp b/tests/test_main_window.cpp new file mode 100644 index 00000000..36773f61 --- /dev/null +++ b/tests/test_main_window.cpp @@ -0,0 +1,145 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "MainWindowController.h" +#include "models/ActionModel.h" +#include "models/ButtonModel.h" +#include "models/DeviceModel.h" +#include "models/ProfileModel.h" +#include "models/SettingsModel.h" +#include "DeviceRegistry.h" +#include "DeviceSession.h" +#include "PhysicalDevice.h" +#include "hidpp/HidrawDevice.h" + +Q_IMPORT_QML_PLUGIN(LogitunePlugin) + +class ObservedDeviceModel : public logitune::DeviceModel +{ +public: + int selectionReceivers() const { return receivers(SIGNAL(selectedChanged())); } +}; + +class MainWindowTest : public ::testing::Test +{ +protected: + void SetUp() override + { + engine.addImportPath("qrc:/"); + engine.rootContext()->setContextProperty("DeviceModel", &deviceModel); + engine.rootContext()->setContextProperty("ButtonModel", &buttonModel); + engine.rootContext()->setContextProperty("ActionModel", &actionModel); + engine.rootContext()->setContextProperty("ProfileModel", &profileModel); + engine.rootContext()->setContextProperty("SettingsModel", &settingsModel); + } + + QQuickWindow *window() + { + if (engine.rootObjects().isEmpty()) + return nullptr; + return qobject_cast(engine.rootObjects().last()); + } + + void addDevice(const QString &id) + { + auto session = std::make_unique( + std::make_unique("/dev/null"), + 0xFF, QStringLiteral("Bluetooth"), ®istry); + session->setConnectedForTest(true); + session->setDeviceNameForTest(id); + auto device = std::make_unique(id); + device->attachTransport(session.get()); + deviceModel.addPhysicalDevice(device.get()); + sessions.push_back(std::move(session)); + devices.push_back(std::move(device)); + } + + logitune::DeviceRegistry registry; + std::vector> sessions; + std::vector> devices; + ObservedDeviceModel deviceModel; + logitune::ButtonModel buttonModel; + logitune::ActionModel actionModel; + logitune::ProfileModel profileModel; + logitune::SettingsModel settingsModel; + QQmlApplicationEngine engine; + logitune::MainWindowController windows{engine}; +}; + +TEST_F(MainWindowTest, StartsWithoutWindowAndLoadsOnlyOnShow) +{ + EXPECT_TRUE(engine.rootObjects().isEmpty()); + QCoreApplication::processEvents(); + ASSERT_TRUE(windows.showMainWindow()); + ASSERT_NE(window(), nullptr); + EXPECT_TRUE(window()->isVisible()); + ASSERT_TRUE(windows.showMainWindow()); + EXPECT_EQ(engine.rootObjects().size(), 1); +} + +TEST_F(MainWindowTest, SelectionAndThemeSurviveReloadAndBackgroundDeviceEvents) +{ + addDevice("first"); + addDevice("second"); + deviceModel.setSelectedIndex(1); + const int backgroundReceivers = deviceModel.selectionReceivers(); + ASSERT_TRUE(windows.showMainWindow()); + EXPECT_EQ(deviceModel.selectedIndex(), 1); + EXPECT_GT(deviceModel.selectionReceivers(), backgroundReceivers); + + QQmlExpression theme(QQmlEngine::contextForObject(window()), window(), "Theme.dark = true"); + theme.evaluate(); + ASSERT_FALSE(theme.hasError()); + + ASSERT_TRUE(window()->close()); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(); + EXPECT_EQ(deviceModel.selectionReceivers(), backgroundReceivers); + EXPECT_EQ(deviceModel.selectedIndex(), 1); + deviceModel.setSelectedIndex(0); + deviceModel.setSelectedIndex(1); + deviceModel.setDisplayValues(3200, true, 50, true, false, "scroll"); + + ASSERT_TRUE(windows.showMainWindow()); + EXPECT_EQ(deviceModel.selectedIndex(), 1); + EXPECT_EQ(deviceModel.currentDPI(), 3200); + QQmlExpression restoredTheme(QQmlEngine::contextForObject(window()), window(), "Theme.dark"); + EXPECT_TRUE(restoredTheme.evaluate().toBool()); + EXPECT_FALSE(restoredTheme.hasError()); +} + +TEST_F(MainWindowTest, CloseDestroysRootAndShowRecreatesIt) +{ + ASSERT_TRUE(windows.showMainWindow()); + QPointer oldWindow = window(); + QSignalSpy destroyed(oldWindow, &QObject::destroyed); + ASSERT_TRUE(oldWindow->close()); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + ASSERT_TRUE(oldWindow.isNull()); + EXPECT_EQ(destroyed.count(), 1); + EXPECT_TRUE(engine.rootObjects().isEmpty()); + + deviceModel.setDisplayValues(2400, true, 50, true, false, "scroll"); + ASSERT_TRUE(windows.showMainWindow()); + ASSERT_NE(window(), nullptr); + EXPECT_TRUE(window()->isVisible()); + EXPECT_EQ(deviceModel.currentDPI(), 2400); +} + +TEST_F(MainWindowTest, ShowDuringDeferredCloseKeepsReplacementAlive) +{ + ASSERT_TRUE(windows.showMainWindow()); + QPointer oldWindow = window(); + ASSERT_TRUE(oldWindow->close()); + ASSERT_TRUE(windows.showMainWindow()); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + EXPECT_TRUE(oldWindow.isNull()); + ASSERT_NE(window(), nullptr); + EXPECT_TRUE(window()->isVisible()); + EXPECT_EQ(engine.rootObjects().size(), 1); +} diff --git a/tests/test_single_instance.cpp b/tests/test_single_instance.cpp index eb30f129..f073af2e 100644 --- a/tests/test_single_instance.cpp +++ b/tests/test_single_instance.cpp @@ -2,6 +2,10 @@ #include #include #include +#include +#include +#include +#include #include "SingleInstance.h" @@ -21,3 +25,55 @@ TEST(SingleInstanceTest, SecondaryRequestsShowAndExitsSuccessfully) ASSERT_TRUE(shown.wait(2000)); EXPECT_EQ(shown.count(), 1); } + +TEST(SingleInstanceTest, SecondaryRequestsQuitAndExitsSuccessfully) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString serverName = "logitune-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + logitune::SingleInstance primary(dir.filePath("instance.lock"), serverName); + ASSERT_FALSE(primary.start().has_value()); + QSignalSpy quit(&primary, &logitune::SingleInstance::quitRequested); + QSignalSpy shown(&primary, &logitune::SingleInstance::showRequested); + + logitune::SingleInstance secondary(dir.filePath("instance.lock"), serverName); + const auto exitCode = secondary.start(logitune::SingleInstance::Command::Quit); + ASSERT_TRUE(exitCode.has_value()); + EXPECT_EQ(*exitCode, 0); + ASSERT_TRUE(quit.wait(2000)); + EXPECT_EQ(quit.count(), 1); + EXPECT_EQ(shown.count(), 0); +} + +TEST(SingleInstanceTest, QuitWithoutPrimaryExitsSuccessfullyWithoutStartingServer) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString serverName = "logitune-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + logitune::SingleInstance instance(dir.filePath("instance.lock"), serverName); + + const auto exitCode = instance.start(logitune::SingleInstance::Command::Quit); + ASSERT_TRUE(exitCode.has_value()); + EXPECT_EQ(*exitCode, 0); + QLocalSocket socket; + socket.connectToServer(serverName); + EXPECT_FALSE(socket.waitForConnected(100)); + EXPECT_FALSE(QFile::exists(dir.filePath("instance.lock"))); +} + +TEST(SingleInstanceTest, QuitCommandLineWithoutPrimaryDoesNotStartApplication) +{ + QTemporaryDir runtime; + ASSERT_TRUE(runtime.isValid()); + QProcess process; + auto environment = QProcessEnvironment::systemEnvironment(); + environment.insert("QT_QPA_PLATFORM", "offscreen"); + environment.insert("XDG_RUNTIME_DIR", runtime.path()); + process.setProcessEnvironment(environment); + process.start(QStringLiteral(LOGITUNE_BINARY), {"--quit"}); + ASSERT_TRUE(process.waitForFinished(5000)); + EXPECT_EQ(process.exitStatus(), QProcess::NormalExit); + EXPECT_EQ(process.exitCode(), 0) << process.readAllStandardError().constData(); + EXPECT_FALSE(QFile::exists(runtime.filePath("logitune.lock"))); + EXPECT_FALSE(QFile::exists(runtime.filePath("logitune"))); +} From 22f777a8b980268409b32ca9381f8628e1e66b0b Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Tue, 3 Nov 2026 23:19:02 +0000 Subject: [PATCH 3/7] feat: add the Actions Ring A radial menu at the cursor, opened by the MX Master 4 haptic button (CID 0x01A0) or logitune --show-ring. Slots are per profile and use the existing action types or a submenu; hover and select play haptic pulses through a new 0x19B0 capability. Hold the trigger past 500 ms and release to select. The overlay uses LayerShellQt when available and asks Hyprland for the cursor position, falling back to the first pointer movement. Colours follow the app theme, overridable with ~/.config/Logitune/actions-ring-theme.json. --- CMakeLists.txt | 2 +- devices/mx-master-4/descriptor.json | 8 +- devices/schema.json | 1 + src/app/AppRoot.cpp | 13 +- src/app/AppRoot.h | 3 + src/app/CMakeLists.txt | 11 + src/app/SingleInstance.cpp | 5 +- src/app/SingleInstance.h | 3 +- src/app/main.cpp | 20 +- src/app/models/ActionFilterModel.cpp | 1 + src/app/models/ActionModel.cpp | 5 + src/app/models/ButtonModel.cpp | 6 + src/app/models/ButtonModel.h | 1 + src/app/qml/Theme.qml | 1 + src/app/qml/components/ActionsPanel.qml | 7 + src/app/qml/components/actionsring/Glyph.qml | 46 ++ src/app/qml/components/actionsring/Ring.qml | 511 ++++++++++++++++++ .../qml/components/actionsring/RingButton.qml | 57 ++ .../qml/components/actionsring/RingEditor.qml | 84 +++ .../components/actionsring/RingSlotEditor.qml | 72 +++ src/app/services/ActionsRingService.cpp | 209 +++++++ src/app/services/ActionsRingService.h | 61 +++ src/app/services/ButtonActionDispatcher.cpp | 22 +- src/app/services/ButtonActionDispatcher.h | 5 + src/app/services/ProfileOrchestrator.cpp | 27 +- src/core/ActionsRing.cpp | 55 ++ src/core/ActionsRing.h | 41 ++ src/core/ButtonAction.h | 1 + src/core/CMakeLists.txt | 3 + src/core/DeviceManager.cpp | 6 + src/core/DeviceManager.h | 1 + src/core/DeviceSession.cpp | 22 +- src/core/DeviceSession.h | 5 + src/core/ProfileEngine.cpp | 13 + src/core/ProfileEngine.h | 4 + src/core/desktop/HyprlandCursor.cpp | 43 ++ src/core/desktop/HyprlandCursor.h | 12 + src/core/hidpp/FeatureDispatcher.cpp | 1 + src/core/hidpp/HidppTypes.h | 1 + .../hidpp/capabilities/HapticsCapability.cpp | 9 + .../hidpp/capabilities/HapticsCapability.h | 14 + src/core/hidpp/features/ReprogControls.cpp | 17 + src/core/hidpp/features/ReprogControls.h | 9 + src/core/input/UinputInjector.cpp | 5 +- tests/CMakeLists.txt | 1 + tests/qml/tst_actions_ring.qml | 78 +++ tests/qml/tst_qml_main.cpp | 17 + .../test_button_action_dispatcher.cpp | 32 ++ tests/test_actions_ring.cpp | 152 ++++++ tests/test_button_action.cpp | 6 + tests/test_button_features.cpp | 21 + tests/test_device_registry.cpp | 15 +- tests/test_single_instance.cpp | 14 + 53 files changed, 1755 insertions(+), 24 deletions(-) create mode 100644 src/app/qml/components/actionsring/Glyph.qml create mode 100644 src/app/qml/components/actionsring/Ring.qml create mode 100644 src/app/qml/components/actionsring/RingButton.qml create mode 100644 src/app/qml/components/actionsring/RingEditor.qml create mode 100644 src/app/qml/components/actionsring/RingSlotEditor.qml create mode 100644 src/app/services/ActionsRingService.cpp create mode 100644 src/app/services/ActionsRingService.h create mode 100644 src/core/ActionsRing.cpp create mode 100644 src/core/ActionsRing.h create mode 100644 src/core/desktop/HyprlandCursor.cpp create mode 100644 src/core/desktop/HyprlandCursor.h create mode 100644 src/core/hidpp/capabilities/HapticsCapability.cpp create mode 100644 src/core/hidpp/capabilities/HapticsCapability.h create mode 100644 tests/qml/tst_actions_ring.qml create mode 100644 tests/test_actions_ring.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 028c6eaf..2dbaacb5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,7 +65,7 @@ project(logitune VERSION ${LOGITUNE_VERSION} LANGUAGES CXX) # first `cmake -B build`. Skipped for source-tarball builds (AUR, OBS) that # have no .git, and no-ops if already pointing at hooks/. # --------------------------------------------------------------------------- -if(Git_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") +if(Git_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git" AND NOT LOGITUNE_NO_GIT_WRITES) execute_process( COMMAND ${GIT_EXECUTABLE} config --local --get core.hooksPath WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} diff --git a/devices/mx-master-4/descriptor.json b/devices/mx-master-4/descriptor.json index 20ce037a..6d36cc53 100644 --- a/devices/mx-master-4/descriptor.json +++ b/devices/mx-master-4/descriptor.json @@ -16,7 +16,7 @@ "reprogControls": true, "gestureV2": false, "mouseGesture": false, - "hapticFeedback": false, + "hapticFeedback": true, "forceSensingButton": false, "crown": false, "reportRate": false, @@ -45,7 +45,8 @@ { "controlId": "0x0056", "buttonIndex": 4, "defaultName": "Forward", "defaultActionType": "default", "configurable": true }, { "controlId": "0x00C3", "buttonIndex": 5, "defaultName": "Gesture button", "defaultActionType": "gesture-trigger", "configurable": true }, { "controlId": "0x00C4", "buttonIndex": 6, "defaultName": "Shift wheel mode", "defaultActionType": "smartshift-toggle", "configurable": true }, - { "controlId": "0x0000", "buttonIndex": 7, "defaultName": "Thumb wheel", "defaultActionType": "default", "configurable": true } + { "controlId": "0x0000", "buttonIndex": 7, "defaultName": "Thumb wheel", "defaultActionType": "default", "configurable": true }, + { "controlId": "0x01A0", "buttonIndex": 8, "defaultName": "Haptic button", "defaultActionType": "actions-ring", "configurable": true } ], "hotspots": { "buttons": [ @@ -54,7 +55,8 @@ { "buttonIndex": 7, "xPct": 0.55, "yPct": 0.515, "side": "right", "labelOffsetYPct": 0.20 }, { "buttonIndex": 4, "xPct": 0.43, "yPct": 0.50, "side": "left", "labelOffsetYPct": 0.0 }, { "buttonIndex": 3, "xPct": 0.47, "yPct": 0.60, "side": "left", "labelOffsetYPct": 0.20 }, - { "buttonIndex": 5, "xPct": 0.39, "yPct": 0.38, "side": "left", "labelOffsetYPct": -0.05 } + { "buttonIndex": 5, "xPct": 0.39, "yPct": 0.38, "side": "left", "labelOffsetYPct": -0.05 }, + { "buttonIndex": 8, "xPct": 0.36, "yPct": 0.73, "side": "left", "labelOffsetYPct": 0.20 } ], "scroll": [ { "buttonIndex": -1, "xPct": 0.73, "yPct": 0.23, "side": "right", "labelOffsetYPct": 0.0, "kind": "scrollwheel" }, diff --git a/devices/schema.json b/devices/schema.json index c0d86a10..921396e3 100644 --- a/devices/schema.json +++ b/devices/schema.json @@ -104,6 +104,7 @@ "default", "keystroke", "gesture-trigger", + "actions-ring", "smartshift-toggle", "dpi-cycle", "app-launch", diff --git a/src/app/AppRoot.cpp b/src/app/AppRoot.cpp index d2b286ec..6afbf1d2 100644 --- a/src/app/AppRoot.cpp +++ b/src/app/AppRoot.cpp @@ -139,6 +139,14 @@ void AppRoot::startMonitoring(bool simulateAll, bool editMode) void AppRoot::wireSignals() { + m_actionsRing = std::make_unique(&m_profileEngine, &m_deviceManager, + &m_actionExecutor, &m_deviceResolver, m_desktop, &m_profileOrchestrator); + connect(&m_buttonDispatcher, &ButtonActionDispatcher::actionsRingTriggered, + m_actionsRing.get(), &ActionsRingService::trigger); + connect(&m_profileEngine, &ProfileEngine::deviceDisplayProfileChanged, + m_actionsRing.get(), &ActionsRingService::editorChanged); + connect(&m_deviceModel, &DeviceModel::selectedChanged, m_actionsRing.get(), + &ActionsRingService::editorChanged); // User edits in the UI (button reassignments, focus-follow from desktop, // tab selection, engine-driven display refresh) route to the orchestrator. connect(&m_buttonModel, &ButtonModel::userActionChanged, @@ -276,7 +284,10 @@ void AppRoot::onPhysicalDeviceAdded(PhysicalDevice *device) connect(device, &PhysicalDevice::gestureRawXY, &m_buttonDispatcher, &ButtonActionDispatcher::onGestureRaw); connect(device, &PhysicalDevice::divertedButtonPressed, - &m_buttonDispatcher, &ButtonActionDispatcher::onDivertedButtonPressed); + &m_buttonDispatcher, [this, device](uint16_t cid, bool pressed) { + m_buttonDispatcher.dispatchButton(device->primary(), device->deviceSerial(), + device->descriptor(), cid, pressed); + }); connect(device, &PhysicalDevice::thumbWheelRotation, &m_buttonDispatcher, &ButtonActionDispatcher::onThumbWheelRotation); diff --git a/src/app/AppRoot.h b/src/app/AppRoot.h index 8f820006..b8817c2e 100644 --- a/src/app/AppRoot.h +++ b/src/app/AppRoot.h @@ -18,6 +18,7 @@ #include "services/DeviceCommandHandler.h" #include "services/ButtonActionDispatcher.h" #include "services/ProfileOrchestrator.h" +#include "services/ActionsRingService.h" #include #include @@ -68,6 +69,7 @@ class AppRoot : public QObject { ProfileModel *profileModel() { return &m_profileModel; } SettingsModel *settingsModel() { return &m_settingsModel; } EditorModel *editorModel() const { return m_editorModel.get(); } + ActionsRingService *actionsRing() { return m_actionsRing.get(); } private slots: void onPhysicalDeviceAdded(PhysicalDevice *device); @@ -107,6 +109,7 @@ private slots: ActionExecutor m_actionExecutor; ButtonActionDispatcher m_buttonDispatcher; ProfileOrchestrator m_profileOrchestrator; + std::unique_ptr m_actionsRing; std::unique_ptr m_editorModel; }; diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index c2c483cd..8fe48e29 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(logitune-app-lib STATIC services/DeviceCommandHandler.cpp services/ButtonActionDispatcher.cpp services/ProfileOrchestrator.cpp + services/ActionsRingService.cpp dialogs/GitHubIssueBuilder.cpp dialogs/CrashReportDialog.cpp TrayManager.cpp @@ -26,6 +27,11 @@ target_include_directories(logitune-app-lib PUBLIC ) target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets Qt6::Network) +find_package(LayerShellQt QUIET) +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}" @@ -72,6 +78,11 @@ qt_add_qml_module(logitune-app-lib qml/components/KeystrokeCapture.qml qml/components/ButtonCallout.qml qml/components/ActionsPanel.qml + qml/components/actionsring/Ring.qml + qml/components/actionsring/RingButton.qml + qml/components/actionsring/Glyph.qml + qml/components/actionsring/RingEditor.qml + qml/components/actionsring/RingSlotEditor.qml qml/components/DeviceCard.qml qml/components/DeviceRender.qml qml/components/AppProfileBar.qml diff --git a/src/app/SingleInstance.cpp b/src/app/SingleInstance.cpp index 1035f904..4f783f17 100644 --- a/src/app/SingleInstance.cpp +++ b/src/app/SingleInstance.cpp @@ -20,6 +20,8 @@ SingleInstance::SingleInstance(const QString &lockPath, const QString &serverNam emit showRequested(); else if (command == "quit\n") emit quitRequested(); + else if (command == "ring\n") + emit ringRequested(); socket->disconnectFromServer(); } else if (socket->bytesAvailable() > 64) { socket->disconnectFromServer(); @@ -52,7 +54,8 @@ std::optional SingleInstance::start(Command command) QLocalSocket socket; socket.connectToServer(m_serverName); if (socket.waitForConnected(100)) { - const QByteArray message = command == Command::Quit ? "quit\n" : "show\n"; + const QByteArray message = command == Command::Quit ? "quit\n" + : command == Command::Ring ? "ring\n" : "show\n"; if (socket.write(message) != message.size()) return 1; if (socket.bytesToWrite() && !socket.waitForBytesWritten(1000)) diff --git a/src/app/SingleInstance.h b/src/app/SingleInstance.h index eb3e210e..4e452798 100644 --- a/src/app/SingleInstance.h +++ b/src/app/SingleInstance.h @@ -12,7 +12,7 @@ class SingleInstance : public QObject { Q_OBJECT public: - enum class Command { Show, Quit }; + enum class Command { Show, Quit, Ring }; SingleInstance(const QString &lockPath, const QString &serverName); // An exit code means this launch must not initialize the devices or UI. @@ -21,6 +21,7 @@ class SingleInstance : public QObject signals: void showRequested(); void quitRequested(); + void ringRequested(); private: QLockFile m_lockFile; diff --git a/src/app/main.cpp b/src/app/main.cpp index 60f6c226..6113d936 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -88,6 +88,9 @@ int main(int argc, char *argv[]) QStringLiteral("quit"), QStringLiteral("Quit the running instance without starting a new one.")); parser.addOption(quitOption); + QCommandLineOption ringOption(QStringLiteral("show-ring"), QStringLiteral("Toggle the Actions Ring")); + ringOption.setFlags(QCommandLineOption::HiddenFromHelp); + parser.addOption(ringOption); parser.process(app); const bool simulateAll = parser.isSet(simulateAllOption); const bool editMode = parser.isSet(editOption); @@ -99,7 +102,8 @@ int main(int argc, char *argv[]) QObject::connect(&singleInstance, &logitune::SingleInstance::quitRequested, &app, &QCoreApplication::quit); const auto command = parser.isSet(quitOption) ? logitune::SingleInstance::Command::Quit - : logitune::SingleInstance::Command::Show; + : parser.isSet(ringOption) ? logitune::SingleInstance::Command::Ring + : logitune::SingleInstance::Command::Show; if (const auto exitCode = singleInstance.start(command)) { if (*exitCode != 0) qCritical() << "Could not start or contact the primary Logitune instance"; @@ -199,6 +203,12 @@ int main(int argc, char *argv[]) } }; engine.addImageProvider(QStringLiteral("icon"), new IconProvider); + controller.actionsRing()->setEngine(&engine); + QObject::connect(&app, &QCoreApplication::aboutToQuit, + controller.actionsRing(), &logitune::ActionsRingService::shutdown); + engine.rootContext()->setContextProperty("ActionsRing", controller.actionsRing()); + QObject::connect(&singleInstance, &logitune::SingleInstance::ringRequested, + controller.actionsRing(), &logitune::ActionsRingService::toggle); // Qt 6.5+: register into the module. Qt 6.4: module is protected by plugin, // use context properties instead (globally available in QML, same effect). @@ -223,6 +233,10 @@ int main(int argc, char *argv[]) "Logitune", 1, 0, "Theme"); #endif + const int themeId = qmlTypeId("Logitune", 1, 0, "Theme"); + if (auto *theme = engine.singletonInstance(themeId)) + theme->setProperty("dark", isDark); + controller.startMonitoring(simulateAll, editMode); if (editMode && controller.editorModel()) { @@ -251,7 +265,7 @@ int main(int argc, char *argv[]) if (!QSystemTrayIcon::isSystemTrayAvailable()) qCInfo(lcApp) << "Tray unavailable -- launch again to show the window; use --quit to exit"; - if (startMinimized) { + if (startMinimized || parser.isSet(ringOption)) { qCInfo(lcApp) << "Startup: running without a window"; } else { if (!windows.showMainWindow()) { @@ -261,6 +275,8 @@ int main(int argc, char *argv[]) } qCInfo(lcApp) << "Startup complete"; + if (parser.isSet(ringOption)) + QTimer::singleShot(0, controller.actionsRing(), &logitune::ActionsRingService::toggle); // Run with exception safety net try { diff --git a/src/app/models/ActionFilterModel.cpp b/src/app/models/ActionFilterModel.cpp index 874397ed..36808a5a 100644 --- a/src/app/models/ActionFilterModel.cpp +++ b/src/app/models/ActionFilterModel.cpp @@ -43,6 +43,7 @@ bool ActionFilterModel::filterAcceptsRow(int sourceRow, // can be extended later to handle them as gesture actions. if (m_gestureMode) { if (type == QLatin1String("gesture-trigger")) return false; + if (type == QLatin1String("actions-ring")) return false; if (type == QLatin1String("none")) return false; } diff --git a/src/app/models/ActionModel.cpp b/src/app/models/ActionModel.cpp index 98b11ee9..1986640c 100644 --- a/src/app/models/ActionModel.cpp +++ b/src/app/models/ActionModel.cpp @@ -8,6 +8,7 @@ ActionModel::ActionModel(QObject *parent) // name description actionType payload category m_actions = { // Device + { "Actions Ring", "Open the profile's radial actions menu", "actions-ring", "", "Device" }, { "DPI cycle", "Step through the device's DPI preset list", "dpi-cycle", "", "Device" }, { "Gestures", "Trigger gesture recognition", "gesture-trigger", "", "Device" }, { "Shift wheel mode", "Toggle scroll wheel ratchet/freespin", "smartshift-toggle", "", "Device" }, @@ -123,6 +124,8 @@ QString ActionModel::buttonActionToName(const ButtonAction &ba) const { if (ba.type == ButtonAction::Default) return QString(); + if (ba.type == ButtonAction::ActionsRing) + return QStringLiteral("Actions Ring"); if (ba.type == ButtonAction::GestureTrigger) return QStringLiteral("Gestures"); if (ba.type == ButtonAction::PresetRef) { @@ -146,6 +149,8 @@ ButtonAction ActionModel::buttonEntryToAction(const QString &actionType, const Q { if (actionType == QStringLiteral("default")) return {ButtonAction::Default, {}}; + if (actionType == QStringLiteral("actions-ring")) + return {ButtonAction::ActionsRing, {}}; if (actionType == QStringLiteral("gesture-trigger")) return {ButtonAction::GestureTrigger, {}}; if (actionType == QStringLiteral("smartshift-toggle")) diff --git a/src/app/models/ButtonModel.cpp b/src/app/models/ButtonModel.cpp index adb89aae..36b99f0a 100644 --- a/src/app/models/ButtonModel.cpp +++ b/src/app/models/ButtonModel.cpp @@ -70,10 +70,16 @@ void ButtonModel::setAction(int buttonId, const QString &actionName, const QStri void ButtonModel::loadFromProfile(const QList &assignments) { beginResetModel(); + while (m_buttons.size() > qMax(8, assignments.size())) m_buttons.removeLast(); + while (m_buttons.size() < assignments.size()) { + const int i = m_buttons.size(); + m_buttons.append({i, assignments[i].buttonName, {}, {}, assignments[i].controlId}); + } for (int i = 0; i < assignments.size() && i < m_buttons.size(); ++i) { m_buttons[i].actionName = assignments[i].actionName; m_buttons[i].actionType = assignments[i].actionType; m_buttons[i].controlId = assignments[i].controlId; + if (!assignments[i].buttonName.isEmpty()) m_buttons[i].buttonName = assignments[i].buttonName; } endResetModel(); diff --git a/src/app/models/ButtonModel.h b/src/app/models/ButtonModel.h index eca94c3e..f9f16354 100644 --- a/src/app/models/ButtonModel.h +++ b/src/app/models/ButtonModel.h @@ -18,6 +18,7 @@ struct ButtonAssignment { QString actionName; QString actionType; uint16_t controlId; + QString buttonName; }; class ButtonModel : public QAbstractListModel { diff --git a/src/app/qml/Theme.qml b/src/app/qml/Theme.qml index 29756448..d7f2ecc5 100644 --- a/src/app/qml/Theme.qml +++ b/src/app/qml/Theme.qml @@ -22,6 +22,7 @@ QtObject { readonly property color selectedBg: accent readonly property color batteryGreen: "#79E053" readonly property color batteryWarning: "#FFA414" + readonly property color error: "#dc3545" // Sidebar active tab readonly property color activeTabBg: accent diff --git a/src/app/qml/components/ActionsPanel.qml b/src/app/qml/components/ActionsPanel.qml index 700d198e..ea662bca 100644 --- a/src/app/qml/components/ActionsPanel.qml +++ b/src/app/qml/components/ActionsPanel.qml @@ -30,6 +30,7 @@ Rectangle { // re-bind). Drives the Loader's fillHeight and the action list's height // cap so the sub-form has room without the picker pushing it offscreen. readonly property bool _hasContextual: currentActionType === "gesture-trigger" + || currentActionType === "actions-ring" || currentAction === "Keyboard shortcut" || isCustomKeystroke @@ -504,6 +505,8 @@ Rectangle { visible: item !== null sourceComponent: { + if (root.currentActionType === "actions-ring") + return ringComponent if (root.currentActionType === "gesture-trigger") return gestureComponent if (root.currentAction === "Keyboard shortcut" || root.isCustomKeystroke) @@ -514,6 +517,10 @@ Rectangle { } // ── Contextual component definitions ─────────────────────────────────── + Component { + id: ringComponent + RingEditor {} + } Component { id: keystrokeComponent diff --git a/src/app/qml/components/actionsring/Glyph.qml b/src/app/qml/components/actionsring/Glyph.qml new file mode 100644 index 00000000..9fde6978 --- /dev/null +++ b/src/app/qml/components/actionsring/Glyph.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Nerd Font icons sit off-centre in their font box, each by a different amount, so a plain +// anchors.centerIn leaves them visibly misaligned. Centre on the glyph's ink bounds instead. +Item { + id: glyph + + required property string icon + property real pixelSize: 20 + property color color: "white" + + anchors.fill: parent + + FontMetrics { + id: fontMetrics + font: label.font + } + + TextMetrics { + id: ink + font: label.font + text: glyph.icon + } + + Text { + id: label + text: themeIcon.visible ? "?" : glyph.icon + visible: !themeIcon.visible || themeIcon.status === Image.Error + color: glyph.color + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: glyph.pixelSize + // tightBoundingRect is measured from the baseline origin; the Text item's baseline is at its ascent. + x: (glyph.width - ink.tightBoundingRect.width) / 2 - ink.tightBoundingRect.x + y: (glyph.height - ink.tightBoundingRect.height) / 2 - ink.tightBoundingRect.y - fontMetrics.ascent + } + + Image { + id: themeIcon + anchors.centerIn: parent + width: glyph.pixelSize + height: glyph.pixelSize + visible: /^[a-zA-Z][a-zA-Z0-9-]+$/.test(glyph.icon) + source: visible ? "image://icon/" + glyph.icon : "" + sourceSize: Qt.size(width, height) + } +} diff --git a/src/app/qml/components/actionsring/Ring.qml b/src/app/qml/components/actionsring/Ring.qml new file mode 100644 index 00000000..0c1a5405 --- /dev/null +++ b/src/app/qml/components/actionsring/Ring.qml @@ -0,0 +1,511 @@ +import QtQuick +import Logitune + +// Sizes and timings follow ~/re/logioptionsplus/findings/02-ring-behaviour.md. Styling is our own. +Item { + id: root + + // The hub is unknown until the pointer reaches this overlay; see place(). + property real hubX: 0 + property real hubY: 0 + property bool isPlaced: false + property string screenName + required property var slotList + required property var launcher + property var themeOverrides: ({}) + readonly property var theme: Object.assign({surface: Theme.surface, surfaceRaised: Theme.cardBg, + text: Theme.text, accent: Theme.accent, onAccent: Theme.activeTabText, + border: Theme.border, error: Theme.error}, themeOverrides) + + property real sizeScale: 1.0 + readonly property real slotRadius: 82 * sizeScale + readonly property real childRadius: 182 * sizeScale + readonly property real buttonSize: 48 * sizeScale + readonly property real closeSize: 32 * sizeScale + readonly property real centreHitRadius: 24 * sizeScale + // Hover reach is much larger than the drawn buttons, so the gaps between them still select something. + readonly property real hoverReach: 162.5 * sizeScale + + readonly property int noTarget: -2 + readonly property int centreTarget: -1 + + // A target is a main slot (or centre/none) plus, inside an open submenu, a child index. + property int hovered: noTarget + property int hoveredChild: -1 + property int pressedSlot: noTarget + property int pressedChild: -1 + property int expandedParent: -1 + property real expansion: 0 + property bool closing: false + + readonly property var expandedChildren: expandedParent >= 0 ? slotList[expandedParent].children : [] + + function slotAngle(index) { + // First slot at 12 o'clock, then clockwise in 45 degree steps. + return index * 45 - 90 + } + + function childAngle(parentIndex, childIndex, childCount) { + // Children are 22.5 degrees apart, as a group centred on the parent's angle. + let offset = (childIndex - (childCount - 1) / 2) * 22.5 + return slotAngle(parentIndex) + offset + } + + function pointOnCircle(radius, angleDegrees) { + let angle = angleDegrees * Math.PI / 180 + return Qt.point(radius * Math.cos(angle), radius * Math.sin(angle)) + } + + function angleBetween(firstDegrees, secondDegrees) { + let difference = Math.abs(firstDegrees - secondDegrees) % 360 + return difference > 180 ? 360 - difference : difference + } + + // Centres of every button currently on screen, so the legend can avoid covering them. + function visibleButtonCentres() { + let centres = [Qt.point(0, 0)] + for (let i = 0; i < slotList.length; i++) + centres.push(pointOnCircle(slotRadius, slotAngle(i))) + for (let c = 0; c < expandedChildren.length; c++) + centres.push(pointOnCircle(childRadius, childAngle(expandedParent, c, expandedChildren.length))) + return centres + } + + function rectangleHitsButton(left, top, width, height, ownCentre) { + let clearance = buttonSize / 2 + 4 + let centres = visibleButtonCentres() + for (let i = 0; i < centres.length; i++) { + let centre = centres[i] + let isOwnButton = Math.abs(centre.x - ownCentre.x) < 1 && Math.abs(centre.y - ownCentre.y) < 1 + if (isOwnButton) + continue + let nearestX = Math.max(left, Math.min(centre.x, left + width)) + let nearestY = Math.max(top, Math.min(centre.y, top + height)) + if (Math.hypot(centre.x - nearestX, centre.y - nearestY) < clearance) + return true + } + return false + } + + // Tries right, then left, then straight out from the hub; keeps the first spot that covers no button. + function legendPlacement(anchor, width, height) { + let gap = buttonSize / 2 + 10 + let outwardDown = anchor.y >= 0 + let candidates = [ + { side: "right", x: anchor.x + gap, y: anchor.y - height / 2 }, + { side: "left", x: anchor.x - gap - width, y: anchor.y - height / 2 }, + outwardDown ? { side: "below", x: anchor.x - width / 2, y: anchor.y + gap } + : { side: "above", x: anchor.x - width / 2, y: anchor.y - gap - height }, + ] + for (let i = 0; i < candidates.length; i++) { + let spot = candidates[i] + if (!rectangleHitsButton(spot.x, spot.y, width, height, anchor)) + return spot + } + return candidates[0] + } + + function easeOut(progress) { + return 1 - Math.pow(1 - progress, 3) + } + + function hasChildren(index) { + return index >= 0 && slotList[index].children !== undefined + } + + function hitTest(x, y) { + let dx = x - hubX + let dy = y - hubY + if (Math.hypot(dx, dy) < centreHitRadius) + return { slot: centreTarget, child: -1 } + + let bestSlot = noTarget + let bestSlotDistance = hoverReach + for (let i = 0; i < slotList.length; i++) { + let centre = pointOnCircle(slotRadius, slotAngle(i)) + let distance = Math.hypot(dx - centre.x, dy - centre.y) + if (distance < bestSlotDistance) { + bestSlot = i + bestSlotDistance = distance + } + } + + let bestChild = -1 + let bestChildDistance = hoverReach + for (let c = 0; c < expandedChildren.length; c++) { + let centre = pointOnCircle(childRadius, childAngle(expandedParent, c, expandedChildren.length)) + let distance = Math.hypot(dx - centre.x, dy - centre.y) + if (distance < bestChildDistance) { + bestChild = c + bestChildDistance = distance + } + } + + // Once a submenu is open, everything 30% of the way from the main ring to the outer ring + // belongs to the submenu, so a short move outward already reaches the children. + let fromHub = Math.hypot(dx, dy) + let submenuBoundary = slotRadius + 0.3 * (childRadius - slotRadius) + if (bestChild >= 0 && fromHub > submenuBoundary) + return { slot: expandedParent, child: bestChild } + + // The open submenu owns the wedge from its parent out to its children, and inside it the + // nearest child is selected, even with the pointer still on the parent. So a click on the + // parent picks the child in front of it, and a small nudge sideways picks a neighbour. + // The wedge also stops a diagonal move from hovering a neighbouring main slot and + // collapsing the submenu on the way out. + if (expandedParent >= 0 && fromHub > slotRadius - buttonSize / 2) { + let fanHalfWidth = (expandedChildren.length - 1) / 2 * 22.5 + 11.25 + let pointerAngle = Math.atan2(dy, dx) * 180 / Math.PI + let insideWedge = angleBetween(pointerAngle, slotAngle(expandedParent)) <= fanHalfWidth + if (insideWedge) + return { slot: expandedParent, child: bestChild } + } + return { slot: bestSlot, child: -1 } + } + + function updateHover(x, y) { + let target = hitTest(x, y) + let changed = target.slot !== hovered || target.child !== hoveredChild + // Moving onto a different item while the button is down cancels that click. + if (changed) { + pressedSlot = noTarget + pressedChild = -1 + } + hovered = target.slot + hoveredChild = target.child + if (changed && hovered >= 0) + launcher.haptic("hover") + + if (hasChildren(hovered)) + expandedParent = hovered + else if (hovered >= 0 && hovered !== expandedParent) + expandedParent = -1 + } + + function choose(slot, child) { + if (closing) + return + if (child >= 0) { + launcher.run(slotList[slot].children[child].action) + dismiss() + return + } + // Clicking a parent only keeps its submenu open. + if (hasChildren(slot)) + return + if (slot >= 0) { + launcher.run(slotList[slot].action) + } + dismiss() + } + + // Holding the trigger past 500 ms and releasing it picks whatever is under the pointer. + function selectHovered() { + choose(hovered, hoveredChild) + } + + function dismiss() { + if (closing) + return + closing = true + closeAnimation.start() + } + + onExpandedParentChanged: { + if (expandedParent >= 0) { + expansion = 0 + expandAnimation.restart() + } else { + expandAnimation.stop() + expansion = 0 + } + } + + focus: true + Keys.onEscapePressed: dismiss() + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + // Only real movement places the ring here (the fallback when the compositor can't be asked). + // onEntered is deliberately unused: Qt fires it on show with a stale, often wrong position. + onPositionChanged: (mouse) => { + if (!root.isPlaced) { + root.place(mouse.x, mouse.y) + return + } + root.updateHover(mouse.x, mouse.y) + } + onPressed: (mouse) => { + if (!root.isPlaced) + return + root.updateHover(mouse.x, mouse.y) + root.pressedSlot = root.hovered + root.pressedChild = root.hoveredChild + } + onReleased: { + if (!root.isPlaced) + return + let sameTarget = root.pressedSlot === root.hovered && root.pressedChild === root.hoveredChild + if (sameTarget) + root.choose(root.hovered, root.hoveredChild) + } + } + + Item { + id: ring + x: root.hubX + y: root.hubY + opacity: 0 + scale: 0 + + // Submenu opening, timed from the parent being hovered (320 ms in total): + // 0-100 ms the nub swells into a short neck + // 100-220 ms a teardrop pinches off and flies out to the child nearest the parent's direction, + // growing to full size, rounded front first, tail pointing back at the parent + // 220-320 ms it becomes that child; the other children grow from dots in place + Item { + id: teardrop + + readonly property real elapsed: root.expansion * 320 + readonly property real neck: root.easeOut(Math.min(elapsed / 100, 1)) + readonly property real flight: root.easeOut(Math.min(Math.max((elapsed - 100) / 120, 0), 1)) + readonly property real arrive: root.easeOut(Math.min(Math.max((elapsed - 220) / 100, 0), 1)) + + readonly property int childCount: root.expandedChildren.length + readonly property int landingChild: Math.floor((childCount - 1) / 2) + readonly property real parentAngle: root.slotAngle(root.expandedParent) + readonly property point launch: root.pointOnCircle(root.slotRadius + root.buttonSize / 2, parentAngle) + readonly property point landing: root.pointOnCircle(root.childRadius, + root.childAngle(root.expandedParent, landingChild, childCount)) + readonly property real heading: Math.atan2(landing.y - launch.y, landing.x - launch.x) + + readonly property point blobCentre: Qt.point(launch.x + (landing.x - launch.x) * flight, + launch.y + (landing.y - launch.y) * flight) + readonly property real blobSize: 16 + (root.buttonSize - 16) * flight + + visible: root.expandedParent >= 0 && arrive < 1 + + // The neck: a short capsule growing out of the nub before the drop pinches off. + Rectangle { + visible: teardrop.elapsed < 100 + readonly property real length: 4 + 14 * teardrop.neck + readonly property real thickness: 12 + 4 * teardrop.neck + readonly property point middle: root.pointOnCircle( + root.slotRadius + root.buttonSize / 2 - 4 + length / 2, teardrop.parentAngle) + width: length + thickness + height: thickness + radius: thickness / 2 + x: middle.x - width / 2 + y: middle.y - height / 2 + rotation: teardrop.parentAngle + color: root.theme.accent + } + + // The tail: a square turned 45 degrees behind the drop, its far corner pointing at the parent. + // It shrinks away as the drop slows down, so the drop lands round. + Rectangle { + visible: teardrop.elapsed >= 100 + readonly property real size: teardrop.blobSize * 0.5 * (1 - teardrop.flight) + readonly property real behind: teardrop.blobSize * 0.42 + width: size + height: size + x: teardrop.blobCentre.x - behind * Math.cos(teardrop.heading) - width / 2 + y: teardrop.blobCentre.y - behind * Math.sin(teardrop.heading) - height / 2 + rotation: teardrop.heading * 180 / Math.PI + 45 + color: root.theme.accent + } + + Rectangle { + visible: teardrop.elapsed >= 100 + width: teardrop.blobSize + height: teardrop.blobSize + radius: width / 2 + x: teardrop.blobCentre.x - width / 2 + y: teardrop.blobCentre.y - height / 2 + color: root.theme.accent + opacity: 1 - teardrop.arrive + } + } + + Repeater { + model: root.expandedChildren + + RingButton { + required property int index + required property var modelData + readonly property bool isLanding: index === teardrop.landingChild + + centre: root.pointOnCircle(root.childRadius, + root.childAngle(root.expandedParent, index, root.expandedChildren.length)) + // The child the drop lands on appears the moment it arrives; the rest grow from dots. + opacity: isLanding ? (teardrop.elapsed >= 220 ? 1 : 0) : Math.min(teardrop.arrive * 3, 1) + baseScale: isLanding ? 1 : 0.2 + 0.8 * teardrop.arrive + theme: root.theme + size: root.buttonSize + icon: modelData.icon + iconSize: 20 * root.sizeScale + hovered: root.hoveredChild === index + } + } + + Repeater { + model: root.slotList + + RingButton { + required property int index + required property var modelData + + centre: root.pointOnCircle(root.slotRadius, root.slotAngle(index)) + theme: root.theme + size: root.buttonSize + icon: modelData.icon + iconSize: 20 * root.sizeScale + hovered: root.hovered === index && root.hoveredChild < 0 + open: root.expandedParent === index + hasSubmenu: root.hasChildren(index) + } + } + + RingButton { + centre: Qt.point(0, 0) + theme: root.theme + size: root.closeSize + icon: "" + iconSize: 14 * root.sizeScale + hovered: root.hovered === root.centreTarget + hoverColor: root.theme.error + hoverScale: 1.0 + } + + // Legend: a pill beside the hovered button, on the side facing away from the hub, shown only + // after the pointer has rested on the button for a while. + Item { + id: legend + + // Slots that open a submenu get no legend; their children say what's inside. + readonly property string label: { + if (root.hoveredChild >= 0) + return root.expandedChildren[root.hoveredChild].name + if (root.hovered >= 0 && !root.hasChildren(root.hovered)) + return root.slotList[root.hovered].name + return "" + } + readonly property point anchorCentre: { + if (root.hoveredChild >= 0) + return root.pointOnCircle(root.childRadius, root.childAngle(root.expandedParent, + root.hoveredChild, root.expandedChildren.length)) + if (root.hovered >= 0) + return root.pointOnCircle(root.slotRadius, root.slotAngle(root.hovered)) + return Qt.point(0, 0) + } + readonly property var placement: root.legendPlacement(anchorCentre, pill.width, pill.height) + property bool shown: false + + visible: label !== "" + opacity: shown ? 1 : 0 + x: placement.x + y: placement.y + + Behavior on opacity { NumberAnimation { duration: 200 } } + + onLabelChanged: { + shown = false + if (label !== "") + legendDelay.restart() + } + + Timer { + id: legendDelay + interval: 800 + onTriggered: legend.shown = true + } + + // Pointer: a small square turned 45 degrees, half hidden behind the pill, on the side facing + // the button. + Rectangle { + readonly property string side: legend.placement.side + width: 8 + height: 8 + rotation: 45 + color: pill.color + x: { + if (side === "right") + return -3 + if (side === "left") + return pill.width - 5 + return pill.width / 2 - 4 + } + y: { + if (side === "below") + return -3 + if (side === "above") + return pill.height - 5 + return pill.height / 2 - 4 + } + } + + Rectangle { + id: pill + width: legendText.implicitWidth + 20 + height: legendText.implicitHeight + 8 + radius: height / 2 + color: root.theme.surfaceRaised + + Text { + id: legendText + anchors.centerIn: parent + text: legend.label + font.pixelSize: 13 * root.sizeScale + color: root.theme.text + } + } + } + } + + // One linear clock for the whole 320 ms; the droplet and the children apply their own easing to it. + NumberAnimation { + id: expandAnimation + target: root; property: "expansion"; from: 0; to: 1; duration: 320 + } + + // The first pointer position this overlay hears about becomes the hub. Most compositors report it + // as soon as the surface appears under the pointer; some only once the mouse moves. + function place(x, y) { + hubX = x + hubY = y + isPlaced = true + console.log("ring placed at", x, y, "on", screenName) + launcher.placed(root) + openAnimation.start() + } + + ParallelAnimation { + id: openAnimation + NumberAnimation { + target: ring; property: "scale"; from: 0; to: 1; duration: 125 + easing.type: Easing.BezierSpline; easing.bezierCurve: [0.075, 0, 0.5, 1, 1, 1] + } + SequentialAnimation { + PauseAnimation { duration: 25 } + NumberAnimation { + target: ring; property: "opacity"; from: 0; to: 1; duration: 100 + easing.type: Easing.BezierSpline; easing.bezierCurve: [0.075, 0, 0.5, 1, 1, 1] + } + } + } + + ParallelAnimation { + id: closeAnimation + NumberAnimation { + target: ring; property: "scale"; to: 0; duration: 125 + easing.type: Easing.BezierSpline; easing.bezierCurve: [0.4, 0, 0.95, 1, 1, 1] + } + NumberAnimation { + target: ring; property: "opacity"; to: 0; duration: 108 + easing.type: Easing.BezierSpline; easing.bezierCurve: [0.4, 0, 0.95, 1, 1, 1] + } + onFinished: launcher.finished() + } +} diff --git a/src/app/qml/components/actionsring/RingButton.qml b/src/app/qml/components/actionsring/RingButton.qml new file mode 100644 index 00000000..d27e2805 --- /dev/null +++ b/src/app/qml/components/actionsring/RingButton.qml @@ -0,0 +1,57 @@ +import QtQuick + +// One round ring button, positioned by its centre relative to the hub. +Rectangle { + id: button + + property point centre + property real size: 48 + property string icon + property real iconSize: 20 + property bool hovered: false + property bool open: false + property bool hasSubmenu: false + property var theme + property color hoverColor: theme.accent + property real hoverScale: 1.12 + property real baseScale: 1.0 + // Resting buttons are slightly see-through so the desktop shows faintly behind the ring. + readonly property color restColor: Qt.rgba(Qt.color(theme.surface).r, Qt.color(theme.surface).g, + Qt.color(theme.surface).b, 0.88) + + x: centre.x - width / 2 + y: centre.y - height / 2 + width: size + height: size + radius: size / 2 + color: hovered || open ? hoverColor : restColor + border.color: theme.border + border.width: 1 + scale: baseScale * hoverAmount + + property real hoverAmount: hovered ? hoverScale : 1.0 + Behavior on hoverAmount { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 120 } } + + // The nub: a small bump on the outer edge of a slot that opens a submenu. Same colour as the + // button so it reads as part of it; the droplet grows out of this spot. + Rectangle { + visible: button.hasSubmenu + z: -1 + width: 12 + height: 12 + radius: 6 + color: button.color + border.color: button.border.color + readonly property real outward: Math.atan2(button.centre.y, button.centre.x) + x: button.width / 2 + (button.width / 2 - 2) * Math.cos(outward) - width / 2 + y: button.height / 2 + (button.height / 2 - 2) * Math.sin(outward) - height / 2 + } + + Glyph { + icon: button.icon + pixelSize: button.iconSize + color: button.hovered || button.open ? button.theme.onAccent : button.theme.text + } + +} diff --git a/src/app/qml/components/actionsring/RingEditor.qml b/src/app/qml/components/actionsring/RingEditor.qml new file mode 100644 index 00000000..6be57cfa --- /dev/null +++ b/src/app/qml/components/actionsring/RingEditor.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +ScrollView { + id: root + property var entries: ActionsRing.entries + property int selectedSlot: 0 + clip: true + contentWidth: availableWidth + + function save(index, entry) { + let copy = entries.slice() + copy[index] = entry + ActionsRing.setEntries(copy) + } + ColumnLayout { + width: root.availableWidth + spacing: 10 + Label { text: "Actions Ring"; color: Theme.text; font.bold: true } + RowLayout { + Label { text: "Size"; color: Theme.text } + ComboBox { + model: ["80%", "100%", "150%"] + currentIndex: [80, 100, 150].indexOf(ActionsRing.size) + onActivated: ActionsRing.size = [80, 100, 150][currentIndex] + } + } + ComboBox { + id: selected + Layout.fillWidth: true + model: root.entries.map((entry, index) => (index + 1) + ". " + entry.name) + currentIndex: root.selectedSlot + onActivated: root.selectedSlot = currentIndex + } + RingSlotEditor { + Layout.fillWidth: true + visible: root.selectedSlot >= 0 + entry: root.entries[root.selectedSlot] || ({}) + allowSubmenu: true + onEdited: (value) => root.save(root.selectedSlot, value) + } + Repeater { + model: (root.entries[root.selectedSlot] || {}).children || [] + ColumnLayout { + id: childRow + required property int index + required property var modelData + Layout.fillWidth: true + Label { text: "Child " + (childRow.index + 1); color: Theme.textSecondary } + RingSlotEditor { + Layout.fillWidth: true + entry: childRow.modelData + onEdited: (value) => { + let parent = Object.assign({}, root.entries[root.selectedSlot]) + parent.children = parent.children.slice() + parent.children[childRow.index] = value + root.save(root.selectedSlot, parent) + } + } + Button { + text: "Remove child" + onClicked: { + let parent = Object.assign({}, root.entries[root.selectedSlot]) + parent.children = parent.children.filter((entry, i) => i !== childRow.index) + if (!parent.children.length) delete parent.children + root.save(root.selectedSlot, parent) + } + } + } + } + Button { + text: "Add child" + visible: ((root.entries[root.selectedSlot] || {}).children || []).length > 0 + enabled: ((root.entries[root.selectedSlot] || {}).children || []).length < 8 + onClicked: { + let parent = Object.assign({}, root.entries[root.selectedSlot]) + parent.children = parent.children.concat([{icon: "", name: "New action", action: "default"}]) + root.save(root.selectedSlot, parent) + } + } + } +} diff --git a/src/app/qml/components/actionsring/RingSlotEditor.qml b/src/app/qml/components/actionsring/RingSlotEditor.qml new file mode 100644 index 00000000..5f704b5d --- /dev/null +++ b/src/app/qml/components/actionsring/RingSlotEditor.qml @@ -0,0 +1,72 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +ColumnLayout { + id: root + required property var entry + property bool allowSubmenu: false + property var choices: [] + signal edited(var value) + + function change(key, value) { + let copy = Object.assign({}, entry) + copy[key] = value + edited(copy) + } + Component.onCompleted: { + let list = [{name: "Choose an action…", action: "default"}] + for (let i = 0; i < ActionModel.rowCount(); i++) { + let index = ActionModel.index(i, 0) + let type = ActionModel.data(index, 259) + if (["keystroke", "media", "app-launch", "dbus", "preset"].indexOf(type) < 0) + continue + list.push({name: ActionModel.data(index, 257), action: type + ":" + ActionModel.data(index, 260)}) + } + choices = list + } + RowLayout { + Layout.fillWidth: true + TextField { + Layout.preferredWidth: 100 + text: root.entry.icon || "" + placeholderText: "Icon / glyph" + color: Theme.text + onEditingFinished: root.change("icon", text) + } + TextField { + Layout.fillWidth: true + text: root.entry.name || "" + placeholderText: "Name" + color: Theme.text + onEditingFinished: root.change("name", text) + } + } + CheckBox { + visible: root.allowSubmenu + text: "Submenu" + checked: root.entry.children !== undefined + onToggled: { + let copy = Object.assign({}, root.entry) + if (checked) copy.children = [{icon: "", name: "New action", action: "default"}] + else delete copy.children + root.edited(copy) + } + } + ComboBox { + Layout.fillWidth: true + visible: root.entry.children === undefined + model: root.choices + textRole: "name" + onActivated: root.change("action", root.choices[currentIndex].action) + } + TextField { + Layout.fillWidth: true + visible: root.entry.children === undefined + text: root.entry.action || "default" + placeholderText: "keystroke:Ctrl+C / app-launch:kitty" + color: Theme.text + onEditingFinished: root.change("action", text) + } +} diff --git a/src/app/services/ActionsRingService.cpp b/src/app/services/ActionsRingService.cpp new file mode 100644 index 00000000..31db1eb7 --- /dev/null +++ b/src/app/services/ActionsRingService.cpp @@ -0,0 +1,209 @@ +#include "ActionsRingService.h" +#include "ActionExecutor.h" +#include "ActiveDeviceResolver.h" +#include "DeviceManager.h" +#include "ProfileEngine.h" +#include "ProfileOrchestrator.h" +#include "desktop/HyprlandCursor.h" +#include "interfaces/IDesktopIntegration.h" +#include "logging/LogManager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef LOGITUNE_LAYER_SHELL +#include +#endif + +namespace logitune { + +ActionsRingService::ActionsRingService(ProfileEngine *profiles, DeviceManager *devices, + ActionExecutor *executor, ActiveDeviceResolver *selection, IDesktopIntegration *desktop, + ProfileOrchestrator *orchestrator, QObject *parent) + : QObject(parent), m_profiles(profiles), m_devices(devices), m_executor(executor), + m_selection(selection), m_desktop(desktop), m_orchestrator(orchestrator) +{ + m_clock.start(); +} + +ActionsRingService::~ActionsRingService() { destroyOverlays(); } + +void ActionsRingService::shutdown() { m_pendingAction = {}; destroyOverlays(); } + +QVariantList ActionsRingService::entries() const +{ + const auto serial = m_selection->activeSerial(); + return ringSlotsToJson(m_profiles->cachedProfile(serial, m_profiles->displayProfile(serial)).ringSlots).toVariantList(); +} + +int ActionsRingService::size() const +{ + const auto serial = m_selection->activeSerial(); + return m_profiles->cachedProfile(serial, m_profiles->displayProfile(serial)).ringSize; +} + +void ActionsRingService::setSize(int size) +{ + if (size != 80 && size != 100 && size != 150) return; + m_orchestrator->applyDisplayedChange([size](Profile &p) { p.ringSize = size; }, [] {}); + emit editorChanged(); +} + +void ActionsRingService::setEntries(const QVariantList &entries) +{ + const auto parsed = ringSlotsFromJson(QJsonArray::fromVariantList(entries)); + m_orchestrator->applyDisplayedChange([&](Profile &p) { p.ringSlots = parsed; }, [] {}); + emit editorChanged(); +} + +void ActionsRingService::trigger(const QString &serial, bool pressed) +{ + if (!pressed && serial != m_serial) return; + const auto result = pressed ? m_trigger.press(m_clock.elapsed()) : m_trigger.release(m_clock.elapsed()); + if (result == ActionsRingTrigger::Open) open(serial); + else if (result == ActionsRingTrigger::Close) dismiss(); + else if (result == ActionsRingTrigger::Select) { + if (m_activeRing) QMetaObject::invokeMethod(m_activeRing, "selectHovered"); + else finished(); + } +} + +void ActionsRingService::toggle() { trigger(m_selection->activeSerial(), true); } + +static QVariantMap themeOverrides() +{ + QString config = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (config.isEmpty()) config = QDir::homePath() + "/.config"; + QFile file(config + "/Logitune/actions-ring-theme.json"); + if (!file.open(QIODevice::ReadOnly)) return {}; + auto colors = QJsonDocument::fromJson(file.readAll()).object(); + if (colors.contains("colors")) colors = colors.value("colors").toObject(); + QVariantMap result; + for (const auto &key : {"surface", "surfaceRaised", "text", "accent", "onAccent", "border", "error"}) { + const auto color = colors.value(QLatin1String(key)).toString(); + if (QColor::isValidColorName(color)) result.insert(QLatin1String(key), color); + } + return result; +} + +void ActionsRingService::open(const QString &serial) +{ + if (!m_engine) { m_trigger.closed(); return; } + m_serial = serial; + m_pendingAction = {}; + const Profile profile = serial.isEmpty() ? Profile{} : m_profiles->cachedProfile(serial, m_profiles->hardwareProfile(serial)); + auto cursor = hyprlandCursor(); + if (cursor) { + const auto screens = QGuiApplication::screens(); + const bool matched = std::any_of(screens.begin(), screens.end(), + [&](QScreen *screen) { return screen->name() == cursor->screenName; }); + if (!matched) cursor.reset(); + } + for (auto *screen : QGuiApplication::screens()) { + if (cursor && cursor->screenName != screen->name()) continue; + auto *view = new QQuickView(m_engine, nullptr); + view->setScreen(screen); + view->setColor(Qt::transparent); + view->setFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool); + view->setResizeMode(QQuickView::SizeRootObjectToView); + view->setInitialProperties({{"slotList", ringSlotsToJson(profile.ringSlots).toVariantList()}, + {"launcher", QVariant::fromValue(this)}, {"themeOverrides", themeOverrides()}, + {"sizeScale", profile.ringSize / 100.0}, {"screenName", screen->name()}}); + view->setSource(QUrl("qrc:/Logitune/qml/components/actionsring/Ring.qml")); + if (view->status() != QQuickView::Ready) { delete view; continue; } + bool layerShell = false; +#ifdef LOGITUNE_LAYER_SHELL + if (QGuiApplication::platformName().startsWith("wayland")) { + auto *layer = LayerShellQt::Window::get(view); + layer->setLayer(LayerShellQt::Window::LayerOverlay); + layer->setAnchors({LayerShellQt::Window::AnchorTop, LayerShellQt::Window::AnchorBottom, + LayerShellQt::Window::AnchorLeft, LayerShellQt::Window::AnchorRight}); + layer->setExclusiveZone(-1); + layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityExclusive); + layer->setScope(QStringLiteral("logitune-actions-ring")); + layer->setWantsToBeOnActiveScreen(false); + layer->setScreen(screen); + layerShell = true; + } +#endif + m_overlays.append(view); + if (cursor) { + auto connection = std::make_shared(); + *connection = connect(view, &QQuickWindow::frameSwapped, this, [this, view, cursor, connection] { + disconnect(*connection); + if (!m_overlays.contains(view)) return; + QMetaObject::invokeMethod(view->rootObject(), "place", + Q_ARG(QVariant, cursor->positionOnScreen.x()), Q_ARG(QVariant, cursor->positionOnScreen.y())); + }, Qt::QueuedConnection); + } + if (layerShell) view->show(); + else { view->setGeometry(screen->geometry()); view->showFullScreen(); } + view->requestActivate(); + } + if (m_overlays.isEmpty()) m_trigger.closed(); + else qCInfo(lcApp) << "Actions Ring opened" << serial << "overlays" << m_overlays.size(); +} + +void ActionsRingService::placed(QObject *ring) +{ + m_activeRing = ring; + for (auto *view : m_overlays) if (view->rootObject() != ring) view->hide(); +} + +void ActionsRingService::haptic(const QString &kind) +{ + if (kind == "hover") m_devices->playHaptic(m_serial, 0x04); +} + +void ActionsRingService::run(const QString &action) +{ + auto parsed = ButtonAction::parse(action); + if (parsed.type == ButtonAction::PresetRef) { + const auto resolved = m_desktop ? m_desktop->resolveNamedAction(parsed.payload) : std::nullopt; + if (!resolved) return; + parsed = *resolved; + } + if (parsed.type != ButtonAction::Keystroke && parsed.type != ButtonAction::Media + && parsed.type != ButtonAction::AppLaunch && parsed.type != ButtonAction::DBus) return; + m_pendingAction = parsed; + m_devices->playHaptic(m_serial, 0x01); +} + +void ActionsRingService::dismiss() +{ + if (m_activeRing) QMetaObject::invokeMethod(m_activeRing, "dismiss"); + else finished(); +} + +void ActionsRingService::destroyOverlays() +{ + qDeleteAll(m_overlays); + m_overlays.clear(); + m_activeRing = nullptr; + m_trigger.closed(); + if (m_engine) m_engine->collectGarbage(); +} + +void ActionsRingService::finished() +{ + // A QML animation calls this; destroy its window after that callback returns. + QTimer::singleShot(0, this, [this] { + const auto action = m_pendingAction; + m_pendingAction = {}; + destroyOverlays(); + qCInfo(lcApp) << "Actions Ring closed"; + if (action.type != ButtonAction::Default) + QTimer::singleShot(30, m_executor, [executor = m_executor, action] { executor->executeAction(action); }); + }); +} +} diff --git a/src/app/services/ActionsRingService.h b/src/app/services/ActionsRingService.h new file mode 100644 index 00000000..59afa468 --- /dev/null +++ b/src/app/services/ActionsRingService.h @@ -0,0 +1,61 @@ +#pragma once +#include "ActionsRing.h" +#include +#include +#include +#include +#include + +class QQmlEngine; +class QQuickView; + +namespace logitune { +class ProfileEngine; +class DeviceManager; +class ActionExecutor; +class ActiveDeviceResolver; +class IDesktopIntegration; +class ProfileOrchestrator; + +class ActionsRingService : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList entries READ entries NOTIFY editorChanged) + Q_PROPERTY(int size READ size WRITE setSize NOTIFY editorChanged) +public: + ActionsRingService(ProfileEngine *profiles, DeviceManager *devices, ActionExecutor *executor, + ActiveDeviceResolver *selection, IDesktopIntegration *desktop, + ProfileOrchestrator *orchestrator, QObject *parent = nullptr); + ~ActionsRingService() override; + void setEngine(QQmlEngine *engine) { m_engine = engine; } + void trigger(const QString &serial, bool pressed); + Q_INVOKABLE void toggle(); + Q_INVOKABLE void dismiss(); + Q_INVOKABLE void haptic(const QString &kind); + Q_INVOKABLE void run(const QString &action); + Q_INVOKABLE void placed(QObject *ring); + Q_INVOKABLE void finished(); + void shutdown(); + QVariantList entries() const; + int size() const; + void setSize(int size); + Q_INVOKABLE void setEntries(const QVariantList &entries); +signals: + void editorChanged(); +private: + void open(const QString &serial); + void destroyOverlays(); + ProfileEngine *m_profiles; + DeviceManager *m_devices; + ActionExecutor *m_executor; + ActiveDeviceResolver *m_selection; + IDesktopIntegration *m_desktop; + ProfileOrchestrator *m_orchestrator; + QPointer m_engine; + QList m_overlays; + QPointer m_activeRing; + QString m_serial; + ActionsRingTrigger m_trigger; + QElapsedTimer m_clock; + ButtonAction m_pendingAction; +}; +} diff --git a/src/app/services/ButtonActionDispatcher.cpp b/src/app/services/ButtonActionDispatcher.cpp index f27c335e..66e0b16b 100644 --- a/src/app/services/ButtonActionDispatcher.cpp +++ b/src/app/services/ButtonActionDispatcher.cpp @@ -52,10 +52,21 @@ void ButtonActionDispatcher::onGestureRaw(int16_t dx, int16_t dy) void ButtonActionDispatcher::onDivertedButtonPressed(uint16_t controlId, bool pressed) { auto *session = m_selection ? m_selection->activeSession() : nullptr; + dispatchButton(session, m_selection ? m_selection->activeSerial() : QString(), + m_currentDevice, controlId, pressed); +} + +void ButtonActionDispatcher::dispatchButton(DeviceSession *session, const QString &serial, + const IDevice *descriptor, uint16_t controlId, bool pressed) +{ if (!session) return; auto &state = m_state[session->deviceId()]; - const QString serial = m_selection->activeSerial(); + if (!pressed && state.ringControlId != 0 && state.ringControlId == controlId) { + state.ringControlId = 0; + emit actionsRingTriggered(serial, false); + return; + } if (serial.isEmpty()) return; const Profile &hwProfile = m_profileEngine->cachedProfile( serial, m_profileEngine->hardwareProfile(serial)); @@ -105,9 +116,9 @@ void ButtonActionDispatcher::onDivertedButtonPressed(uint16_t controlId, bool pr if (!pressed) return; - if (!m_currentDevice) return; + if (!descriptor) return; int idx = -1; - for (const auto &ctrl : m_currentDevice->controls()) { + for (const auto &ctrl : descriptor->controls()) { if (ctrl.controlId == controlId) { idx = ctrl.buttonIndex; break; @@ -123,7 +134,10 @@ void ButtonActionDispatcher::onDivertedButtonPressed(uint16_t controlId, bool pr if (ba.type == ButtonAction::Default) return; - if (ba.type == ButtonAction::SmartShiftToggle) { + if (ba.type == ButtonAction::ActionsRing) { + state.ringControlId = controlId; + emit actionsRingTriggered(serial, true); + } else if (ba.type == ButtonAction::SmartShiftToggle) { bool current = session->smartShiftEnabled(); session->setSmartShift(!current, session->smartShiftThreshold()); emit smartShiftChangedByButton(!current, session->smartShiftThreshold()); diff --git a/src/app/services/ButtonActionDispatcher.h b/src/app/services/ButtonActionDispatcher.h index 940a5eda..1ce14092 100644 --- a/src/app/services/ButtonActionDispatcher.h +++ b/src/app/services/ButtonActionDispatcher.h @@ -16,6 +16,7 @@ class ActiveDeviceResolver; class ProfileEngine; class IDevice; class IDesktopIntegration; +class DeviceSession; /// Turns raw HID++ input events (gestureRawXY, divertedButtonPressed, /// thumbWheelRotation) into high-level actions (SmartShift toggle, DPI @@ -32,11 +33,14 @@ class ButtonActionDispatcher : public QObject { QObject *parent = nullptr); void onDeviceRemoved(const QString &serial); + void dispatchButton(DeviceSession *session, const QString &serial, + const IDevice *descriptor, uint16_t controlId, bool pressed); friend class test::AppRootFixture; friend class test::ButtonActionDispatcherFixture; signals: + void actionsRingTriggered(const QString &serial, bool pressed); /// Emitted after a diverted button changes a device setting, so the /// orchestrator can persist it into the active profile. Notifying /// explicitly (rather than having the orchestrator listen to @@ -59,6 +63,7 @@ public slots: int thumbAccum = 0; bool gestureActive = false; uint16_t gestureControlId = 0; + uint16_t ringControlId = 0; }; static constexpr int kGestureThreshold = 50; static constexpr int kThumbThreshold = 15; diff --git a/src/app/services/ProfileOrchestrator.cpp b/src/app/services/ProfileOrchestrator.cpp index 294b252a..c8152e69 100644 --- a/src/app/services/ProfileOrchestrator.cpp +++ b/src/app/services/ProfileOrchestrator.cpp @@ -100,6 +100,8 @@ void ProfileOrchestrator::setupProfileForDevice(PhysicalDevice *device) seed.buttons[i] = {ButtonAction::SmartShiftToggle, {}}; else if (ctrl.defaultActionType == "dpi-cycle") seed.buttons[i] = {ButtonAction::DpiCycle, {}}; + else if (ctrl.defaultActionType == "actions-ring") + seed.buttons[i] = {ButtonAction::ActionsRing, {}}; } const auto defaultGestures = m_currentDevice->defaultGestures(); for (auto it = defaultGestures.begin(); @@ -112,6 +114,23 @@ void ProfileOrchestrator::setupProfileForDevice(PhysicalDevice *device) qCDebug(lcApp) << "created default profile at" << defaultConf; } + for (const auto &name : m_profileEngine->profileNames(serial)) { + auto &profile = m_profileEngine->cachedProfile(serial, name); + if (profile.ringButtonMigrated) continue; + bool hasRingButton = false; + for (const auto &control : device->descriptor()->controls()) { + if (control.defaultActionType == "actions-ring") hasRingButton = true; + if (control.defaultActionType == "actions-ring" + && control.buttonIndex >= 0 && control.buttonIndex < int(profile.buttons.size()) + && profile.buttons[control.buttonIndex].type == ButtonAction::Default) + profile.buttons[control.buttonIndex] = {ButtonAction::ActionsRing, {}}; + } + if (hasRingButton) { + profile.ringButtonMigrated = true; + m_profileEngine->saveProfileToDisk(serial, name); + } + } + const QString bindingsFile = profilesDir + QStringLiteral("/app-bindings.conf"); if (QFile::exists(bindingsFile)) { const auto bindings = ProfileEngine::loadAppBindings(bindingsFile); @@ -257,7 +276,7 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p) }; QString wheelName = kWheelNames.value(p.thumbWheelMode, p.thumbWheelMode); QString wheelType = (p.thumbWheelMode == "scroll") ? "default" : "wheel-mode"; - assignments.append({wheelName, wheelType, ctrl.controlId}); + assignments.append({wheelName, wheelType, ctrl.controlId, ctrl.defaultName}); continue; } const auto &ba = (static_cast(i) < p.buttons.size()) @@ -274,6 +293,10 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p) aType = QStringLiteral("gesture-trigger"); aName = QStringLiteral("Gestures"); break; + case ButtonAction::ActionsRing: + aType = QStringLiteral("actions-ring"); + aName = QStringLiteral("Actions Ring"); + break; case ButtonAction::SmartShiftToggle: aType = QStringLiteral("smartshift-toggle"); aName = QStringLiteral("Shift wheel mode"); @@ -309,7 +332,7 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p) aName = ctrl.defaultName; break; } - assignments.append({aName, aType, ctrl.controlId}); + assignments.append({aName, aType, ctrl.controlId, ctrl.defaultName}); } m_buttonModel->loadFromProfile(assignments); diff --git a/src/core/ActionsRing.cpp b/src/core/ActionsRing.cpp new file mode 100644 index 00000000..c271f433 --- /dev/null +++ b/src/core/ActionsRing.cpp @@ -0,0 +1,55 @@ +#include "ActionsRing.h" +#include + +namespace logitune { + +QList defaultRingSlots() +{ + return { + {"", "Terminal", {ButtonAction::AppLaunch, "kitty"}, {}}, + {"", "Files", {ButtonAction::AppLaunch, "xdg-open ."}, {}}, + {"", "Copy", {ButtonAction::Keystroke, "Ctrl+C"}, {}}, + {"", "Paste", {ButtonAction::Keystroke, "Ctrl+V"}, {}}, + {"", "Media", {}, { + {"", "Previous", {ButtonAction::Media, "Previous"}, {}}, + {"", "Play / pause", {ButtonAction::Media, "Play"}, {}}, + {"", "Next", {ButtonAction::Media, "Next"}, {}}}}, + {"", "Mute", {ButtonAction::Media, "Mute"}, {}}, + {"", "Undo", {ButtonAction::Keystroke, "Ctrl+Z"}, {}}, + {"", "Redo", {ButtonAction::Keystroke, "Ctrl+Shift+Z"}, {}} + }; +} + +static QJsonArray encodeSlots(const QList &entries, bool children) +{ + QJsonArray array; + for (const auto &slot : entries.mid(0, 8)) { + QJsonObject object{{"icon", slot.icon}, {"name", slot.name}, + {"action", slot.action.serialize()}}; + if (children && !slot.children.isEmpty()) + object.insert("children", encodeSlots(slot.children, false)); + array.append(object); + } + return array; +} + +QJsonArray ringSlotsToJson(const QList &entries) { return encodeSlots(entries, true); } + +static QList decodeSlots(const QJsonArray &array, bool children) +{ + QList entries; + for (const auto &value : array) { + if (entries.size() == 8) break; + if (!value.isObject()) continue; + const auto object = value.toObject(); + RingSlot slot{object.value("icon").toString(), object.value("name").toString(), + ButtonAction::parse(object.value("action").toString()), {}}; + if (children) slot.children = decodeSlots(object.value("children").toArray(), false); + entries.append(slot); + } + return entries; +} + +QList ringSlotsFromJson(const QJsonArray &array) { return decodeSlots(array, true); } + +} // namespace logitune diff --git a/src/core/ActionsRing.h b/src/core/ActionsRing.h new file mode 100644 index 00000000..9185e8cd --- /dev/null +++ b/src/core/ActionsRing.h @@ -0,0 +1,41 @@ +#pragma once +#include "ButtonAction.h" +#include +#include + +namespace logitune { + +struct RingSlot { + QString icon; + QString name; + ButtonAction action; + QList children; +}; + +QList defaultRingSlots(); +QJsonArray ringSlotsToJson(const QList &entries); +QList ringSlotsFromJson(const QJsonArray &array); + +class ActionsRingTrigger { +public: + enum Result { None, Open, Close, Select }; + Result press(qint64 now) { + if (m_open) { m_held = false; return Close; } + m_open = true; + m_held = true; + m_pressedAt = now; + return Open; + } + Result release(qint64 now) { + if (!m_held) return None; + m_held = false; + return m_open && now - m_pressedAt > 500 ? Select : None; + } + void closed() { m_open = false; m_held = false; } +private: + bool m_open = false; + bool m_held = false; + qint64 m_pressedAt = 0; +}; + +} // namespace logitune diff --git a/src/core/ButtonAction.h b/src/core/ButtonAction.h index f3dd41d6..9691d8c5 100644 --- a/src/core/ButtonAction.h +++ b/src/core/ButtonAction.h @@ -14,6 +14,7 @@ struct ButtonAction { DBus, Media, PresetRef, + ActionsRing, }; Type type = Default; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 7589e639..e8f58d32 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -21,6 +21,8 @@ target_sources(logitune-core PRIVATE hidpp/capabilities/SmartShiftCapability.cpp hidpp/capabilities/ReprogControlsCapability.cpp ProfileEngine.cpp + ActionsRing.cpp + hidpp/capabilities/HapticsCapability.cpp ActionExecutor.cpp DeviceRegistry.cpp devices/DescriptorWriter.cpp @@ -33,6 +35,7 @@ target_sources(logitune-core PRIVATE desktop/LinuxDesktopBase.cpp desktop/GnomeDesktop.cpp desktop/HyprlandDesktop.cpp + desktop/HyprlandCursor.cpp desktop/KDeDesktop.cpp desktop/GenericDesktop.cpp input/UinputInjector.cpp diff --git a/src/core/DeviceManager.cpp b/src/core/DeviceManager.cpp index 30fc1fe4..0b834dd5 100644 --- a/src/core/DeviceManager.cpp +++ b/src/core/DeviceManager.cpp @@ -19,6 +19,12 @@ namespace logitune { +void DeviceManager::playHaptic(const QString &serial, uint8_t waveform) +{ + auto *device = physicalDeviceBySerial(serial); + if (device && device->primary()) device->primary()->playHaptic(waveform); +} + // --------------------------------------------------------------------------- // Static helpers // --------------------------------------------------------------------------- diff --git a/src/core/DeviceManager.h b/src/core/DeviceManager.h index 678facf7..e4376a8a 100644 --- a/src/core/DeviceManager.h +++ b/src/core/DeviceManager.h @@ -45,6 +45,7 @@ class DeviceManager : public QObject { // Physical device access (logical layer, one per unique serial). QList physicalDevices() const; PhysicalDevice *physicalDeviceBySerial(const QString &serial) const; + void playHaptic(const QString &serial, uint8_t waveform); // Backward compat — returns first session's descriptor const IDevice* activeDevice() const; diff --git a/src/core/DeviceSession.cpp b/src/core/DeviceSession.cpp index bca9bab5..2ff70c39 100644 --- a/src/core/DeviceSession.cpp +++ b/src/core/DeviceSession.cpp @@ -265,6 +265,9 @@ void DeviceSession::enumerateAndSetup() } // Resolve variant dispatches + m_divertedButtons.clear(); + m_hapticsDispatch = hidpp::capabilities::resolveCapability( + m_features.get(), hidpp::capabilities::kHapticsVariants); m_batteryDispatch = hidpp::capabilities::resolveCapability( m_features.get(), hidpp::capabilities::kBatteryVariants); m_smartShiftDispatch = hidpp::capabilities::resolveCapability( @@ -652,12 +655,11 @@ void DeviceSession::handleNotification(const hidpp::Report &report) auto idx = m_features->featureIndex(m_reprogControlsDispatch->feature); if (idx.has_value() && report.featureIndex == *idx) { if (report.functionId == 0) { - uint16_t controlId = (static_cast(report.params[0]) << 8) - | report.params[1]; - bool pressed = (controlId != 0); - qCDebug(lcDevice) << "button event: CID" << Qt::hex << controlId - << (pressed ? "pressed" : "released"); - emit divertedButtonPressed(controlId, pressed); + for (const auto &[controlId, pressed] : m_divertedButtons.update(report)) { + qCDebug(lcDevice) << "button event: CID" << Qt::hex << controlId + << (pressed ? "pressed" : "released"); + emit divertedButtonPressed(controlId, pressed); + } } else if (report.functionId == 1) { auto evt = hidpp::features::ReprogControls::parseDivertedRawXYEvent(report); emit gestureRawXY(evt.dx, evt.dy); @@ -827,6 +829,14 @@ void DeviceSession::divertButton(uint16_t controlId, bool divert, bool rawXY) << (divert ? "diverted" : "undiverted") << (rawXY ? "+rawXY" : ""); } +void DeviceSession::playHaptic(uint8_t waveform) +{ + if (!m_connected || !m_commandProcessor || !m_hapticsDispatch) return; + const auto params = m_hapticsDispatch->buildPlay(waveform); + m_commandProcessor->enqueue(m_hapticsDispatch->feature, m_hapticsDispatch->playFn, + std::span(params)); +} + QString DeviceSession::thumbWheelMode() const { return m_thumbWheelMode; } bool DeviceSession::thumbWheelInvert() const { return m_thumbWheelInvert; } int DeviceSession::thumbWheelDefaultDirection() const { return m_thumbWheelDefaultDirection; } diff --git a/src/core/DeviceSession.h b/src/core/DeviceSession.h index 1f5de69c..468b81b9 100644 --- a/src/core/DeviceSession.h +++ b/src/core/DeviceSession.h @@ -7,6 +7,8 @@ #include "hidpp/capabilities/BatteryCapability.h" #include "hidpp/capabilities/SmartShiftCapability.h" #include "hidpp/capabilities/ReprogControlsCapability.h" +#include "hidpp/capabilities/HapticsCapability.h" +#include "hidpp/features/ReprogControls.h" #include #include #include @@ -77,6 +79,7 @@ class DeviceSession : public QObject { void setSmartShift(bool enabled, int threshold); void setScrollConfig(bool hiRes, bool invert); void divertButton(uint16_t controlId, bool divert, bool rawXY = false); + void playHaptic(uint8_t waveform); void setThumbWheelMode(const QString &mode, bool invert = false); void flushCommandProcessor(); void touchResponseTime(); @@ -140,6 +143,8 @@ class DeviceSession : public QObject { std::optional m_batteryDispatch; std::optional m_smartShiftDispatch; std::optional m_reprogControlsDispatch; + std::optional m_hapticsDispatch; + hidpp::features::DivertedButtons m_divertedButtons; QTimer *m_reconnectTimer = nullptr; QTimer *m_batteryPollTimer = nullptr; diff --git a/src/core/ProfileEngine.cpp b/src/core/ProfileEngine.cpp index bc26b945..10fca854 100644 --- a/src/core/ProfileEngine.cpp +++ b/src/core/ProfileEngine.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace logitune { @@ -20,6 +21,9 @@ ButtonAction ButtonAction::parse(const QString &str) if (str == "gesture-trigger") return {GestureTrigger, {}}; + if (str == "actions-ring") + return {ActionsRing, {}}; + if (str == "smartshift-toggle") return {SmartShiftToggle, {}}; @@ -51,6 +55,7 @@ QString ButtonAction::serialize() const { switch (type) { case Default: return "default"; + case ActionsRing: return "actions-ring"; case GestureTrigger: return "gesture-trigger"; case SmartShiftToggle: return "smartshift-toggle"; case DpiCycle: return "dpi-cycle"; @@ -88,6 +93,11 @@ Profile ProfileEngine::loadProfile(const QString &path) p.thumbWheelMode = s.value("ThumbWheel/mode", "scroll").toString(); p.thumbWheelInvert = s.value("ThumbWheel/invert", false).toBool(); + p.ringButtonMigrated = s.value("ActionsRing/buttonMigrated", false).toBool(); + if (s.contains("ActionsRing/slots")) + p.ringSlots = ringSlotsFromJson(QJsonDocument::fromJson(s.value("ActionsRing/slots").toByteArray()).array()); + p.ringSize = s.value("ActionsRing/size", 100).toInt(); + if (p.ringSize != 80 && p.ringSize != 150) p.ringSize = 100; s.beginGroup("Buttons"); const QStringList buttonKeys = s.childKeys(); @@ -128,6 +138,9 @@ void ProfileEngine::saveProfile(const QString &path, const Profile &profile) s.setValue("ThumbWheel/mode", profile.thumbWheelMode); s.setValue("ThumbWheel/invert", profile.thumbWheelInvert); + s.setValue("ActionsRing/slots", QJsonDocument(ringSlotsToJson(profile.ringSlots)).toJson(QJsonDocument::Compact)); + s.setValue("ActionsRing/size", profile.ringSize); + s.setValue("ActionsRing/buttonMigrated", profile.ringButtonMigrated); s.beginGroup("Buttons"); for (std::size_t i = 0; i < profile.buttons.size(); ++i) diff --git a/src/core/ProfileEngine.h b/src/core/ProfileEngine.h index 6ff624ca..187e629b 100644 --- a/src/core/ProfileEngine.h +++ b/src/core/ProfileEngine.h @@ -1,5 +1,6 @@ #pragma once #include "ButtonAction.h" +#include "ActionsRing.h" #include #include #include @@ -10,6 +11,9 @@ namespace logitune { struct Profile { + QList ringSlots = defaultRingSlots(); + int ringSize = 100; + bool ringButtonMigrated = false; int version = 1; QString name; QString icon; diff --git a/src/core/desktop/HyprlandCursor.cpp b/src/core/desktop/HyprlandCursor.cpp new file mode 100644 index 00000000..d3bf22d0 --- /dev/null +++ b/src/core/desktop/HyprlandCursor.cpp @@ -0,0 +1,43 @@ +#include "HyprlandCursor.h" +#include +#include +#include +#include +#include + +namespace logitune { + +static QJsonDocument askHyprland(const QByteArray &request) +{ + const auto signature = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (signature.isEmpty()) return {}; + const auto path = qEnvironmentVariable("XDG_RUNTIME_DIR") + "/hypr/" + signature + "/.socket.sock"; + QLocalSocket socket; + socket.connectToServer(path); + if (!socket.waitForConnected(200)) return {}; + socket.write(request); + if (!socket.waitForBytesWritten(200)) return {}; + QByteArray reply; + while (socket.waitForReadyRead(200)) reply += socket.readAll(); + return QJsonDocument::fromJson(reply); +} + +std::optional hyprlandCursor() +{ + const auto cursor = askHyprland("j/cursorpos").object(); + if (!cursor.contains("x") || !cursor.contains("y")) return std::nullopt; + const QPointF position(cursor.value("x").toDouble(), cursor.value("y").toDouble()); + for (const auto &value : askHyprland("j/monitors").array()) { + const auto monitor = value.toObject(); + const double scale = monitor.value("scale").toDouble(1); + if (scale <= 0) continue; + double width = monitor.value("width").toDouble() / scale; + double height = monitor.value("height").toDouble() / scale; + if (monitor.value("transform").toInt() % 2) std::swap(width, height); + const QRectF bounds(monitor.value("x").toDouble(), monitor.value("y").toDouble(), width, height); + if (bounds.contains(position)) + return CursorSpot{monitor.value("name").toString(), position - bounds.topLeft()}; + } + return std::nullopt; +} +} diff --git a/src/core/desktop/HyprlandCursor.h b/src/core/desktop/HyprlandCursor.h new file mode 100644 index 00000000..5f256e82 --- /dev/null +++ b/src/core/desktop/HyprlandCursor.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include +#include + +namespace logitune { +struct CursorSpot { + QString screenName; + QPointF positionOnScreen; +}; +std::optional hyprlandCursor(); +} diff --git a/src/core/hidpp/FeatureDispatcher.cpp b/src/core/hidpp/FeatureDispatcher.cpp index 1845b054..8c8dfc95 100644 --- a/src/core/hidpp/FeatureDispatcher.cpp +++ b/src/core/hidpp/FeatureDispatcher.cpp @@ -12,6 +12,7 @@ static constexpr FeatureId kKnownFeatures[] = { FeatureId::BatteryStatus, FeatureId::BatteryUnified, FeatureId::ChangeHost, + FeatureId::Haptics, FeatureId::ReprogControls, FeatureId::ReprogControlsV2, FeatureId::ReprogControlsV2_2, diff --git a/src/core/hidpp/HidppTypes.h b/src/core/hidpp/HidppTypes.h index e4b92537..e1ba6581 100644 --- a/src/core/hidpp/HidppTypes.h +++ b/src/core/hidpp/HidppTypes.h @@ -27,6 +27,7 @@ enum class FeatureId : uint16_t { BatteryStatus = 0x1000, BatteryUnified = 0x1004, ChangeHost = 0x1814, + Haptics = 0x19b0, ReprogControls = 0x1b00, ReprogControlsV2 = 0x1b01, ReprogControlsV2_2= 0x1b02, diff --git a/src/core/hidpp/capabilities/HapticsCapability.cpp b/src/core/hidpp/capabilities/HapticsCapability.cpp new file mode 100644 index 00000000..3fc3e5fe --- /dev/null +++ b/src/core/hidpp/capabilities/HapticsCapability.cpp @@ -0,0 +1,9 @@ +#include "HapticsCapability.h" + +namespace logitune::hidpp::capabilities { + +static std::vector buildPlay(uint8_t waveform) { return {waveform}; } + +const HapticsVariant kHapticsVariants[1] = {{FeatureId::Haptics, 4, &buildPlay}}; + +} // namespace logitune::hidpp::capabilities diff --git a/src/core/hidpp/capabilities/HapticsCapability.h b/src/core/hidpp/capabilities/HapticsCapability.h new file mode 100644 index 00000000..e1d8948c --- /dev/null +++ b/src/core/hidpp/capabilities/HapticsCapability.h @@ -0,0 +1,14 @@ +#pragma once +#include "hidpp/HidppTypes.h" + +namespace logitune::hidpp::capabilities { + +struct HapticsVariant { + FeatureId feature; + uint8_t playFn; + std::vector (*buildPlay)(uint8_t waveform); +}; + +extern const HapticsVariant kHapticsVariants[1]; + +} // namespace logitune::hidpp::capabilities diff --git a/src/core/hidpp/features/ReprogControls.cpp b/src/core/hidpp/features/ReprogControls.cpp index 3ce3ec9a..6d51d4b9 100644 --- a/src/core/hidpp/features/ReprogControls.cpp +++ b/src/core/hidpp/features/ReprogControls.cpp @@ -1,7 +1,24 @@ #include "hidpp/features/ReprogControls.h" +#include namespace logitune::hidpp::features { +std::vector> DivertedButtons::update(const Report &report) +{ + std::vector held; + for (int offset = 0; offset + 1 < std::min(8, report.paramLength); offset += 2) { + const uint16_t cid = (report.params[offset] << 8) | report.params[offset + 1]; + if (cid && std::find(held.begin(), held.end(), cid) == held.end()) held.push_back(cid); + } + std::vector> events; + for (auto cid : m_held) + if (std::find(held.begin(), held.end(), cid) == held.end()) events.emplace_back(cid, false); + for (auto cid : held) + if (std::find(m_held.begin(), m_held.end(), cid) == m_held.end()) events.emplace_back(cid, true); + m_held = std::move(held); + return events; +} + int ReprogControls::parseControlCount(const Report &r) { return static_cast(r.params[0]); diff --git a/src/core/hidpp/features/ReprogControls.h b/src/core/hidpp/features/ReprogControls.h index 135130db..fe91457e 100644 --- a/src/core/hidpp/features/ReprogControls.h +++ b/src/core/hidpp/features/ReprogControls.h @@ -2,6 +2,7 @@ #include "hidpp/HidppTypes.h" #include #include +#include namespace logitune::hidpp::features { @@ -18,6 +19,14 @@ struct RawXYEvent { int16_t dy; }; +class DivertedButtons { +public: + std::vector> update(const Report &report); + void clear() { m_held.clear(); } +private: + std::vector m_held; +}; + class ReprogControls { public: static int parseControlCount(const Report &r); diff --git a/src/core/input/UinputInjector.cpp b/src/core/input/UinputInjector.cpp index 4fb2a6b8..085e75d0 100644 --- a/src/core/input/UinputInjector.cpp +++ b/src/core/input/UinputInjector.cpp @@ -199,7 +199,10 @@ void UinputInjector::sendDBusCall(const QString &spec) void UinputInjector::launchApp(const QString &command) { - QProcess::startDetached(command); + auto arguments = QProcess::splitCommand(command); + if (arguments.isEmpty()) return; + const auto program = arguments.takeFirst(); + QProcess::startDetached(program, arguments); } // --------------------------------------------------------------------------- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 901f09cf..153d7a3d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(logitune-tests mocks/MockTransport.cpp test_keystroke_parser.cpp test_button_action.cpp + test_actions_ring.cpp test_button_model.cpp test_profile_model.cpp test_action_model.cpp diff --git a/tests/qml/tst_actions_ring.qml b/tests/qml/tst_actions_ring.qml new file mode 100644 index 00000000..90911f70 --- /dev/null +++ b/tests/qml/tst_actions_ring.qml @@ -0,0 +1,78 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "ActionsRing" + when: windowShown + width: 800 + height: 800 + QtObject { + id: bridge + property int pulses: 0 + property string chosen: "" + property bool closed: false + function haptic(kind) { pulses++ } + function run(action) { chosen = action } + function placed(ring) {} + function finished() { closed = true } + } + Ring { + id: ring + anchors.fill: parent + launcher: bridge + slotList: [ + {icon: "A", name: "One", action: "keystroke:Ctrl+C"}, + {icon: "B", name: "Two", action: "media:VolumeMute"}, + {icon: "C", name: "Three", action: "default"}, + {icon: "D", name: "Four", action: "default"}, + {icon: "E", name: "Media", children: [ + {icon: "F", name: "Previous", action: "media:MediaPrevious"}, + {icon: "G", name: "Play", action: "media:MediaPlay"}, + {icon: "H", name: "Next", action: "media:MediaNext"}]}] + } + function init() { + ring.hubX = 400; ring.hubY = 400 + ring.expandedParent = -1 + ring.hovered = ring.noTarget + ring.hoveredChild = -1 + bridge.pulses = 0; bridge.chosen = "" + } + function test_centreAndReach() { + compare(ring.hitTest(400, 400).slot, ring.centreTarget) + compare(ring.hitTest(400, 318).slot, 0) + compare(ring.hitTest(790, 400).slot, ring.noTarget) + } + function test_parentWedgeSelectsNearestChild() { + ring.updateHover(400, 482) + compare(ring.expandedParent, 4) + compare(ring.hitTest(400, 482).child, 1) + compare(ring.hitTest(400, 520).child, 1) + } + function test_hoverOnlyPulsesOnChange() { + ring.updateHover(400, 318) + compare(bridge.pulses, 1) + ring.updateHover(401, 318) + compare(bridge.pulses, 1) + } + function test_integratedActionNotShellCommand() { + ring.choose(0, -1) + compare(bridge.chosen, "keystroke:Ctrl+C") + } + Component { id: editorComponent; RingEditor {} } + function test_editorLoadsAndEdits() { + let editor = createTemporaryObject(editorComponent, this, {width: 360, height: 500}) + verify(editor !== null) + compare(editor.entries.length, 8) + let entry = Object.assign({}, editor.entries[0]) + entry.name = "Edited terminal" + editor.save(0, entry) + compare(editor.entries[0].name, "Edited terminal") + editor.selectedSlot = 6 + entry = Object.assign({}, editor.entries[6]) + entry.name = "Edited undo" + editor.save(6, entry) + compare(editor.selectedSlot, 6) + compare(editor.entries[6].name, "Edited undo") + } +} diff --git a/tests/qml/tst_qml_main.cpp b/tests/qml/tst_qml_main.cpp index 926ff3f8..e509030b 100644 --- a/tests/qml/tst_qml_main.cpp +++ b/tests/qml/tst_qml_main.cpp @@ -9,6 +9,21 @@ #include "models/ProfileModel.h" #include "models/SettingsModel.h" #include "logging/LogManager.h" +#include "ActionsRing.h" + +class RingEditorStub : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList entries READ entries NOTIFY editorChanged) + Q_PROPERTY(int size MEMBER m_size NOTIFY editorChanged) +public: + QVariantList entries() const { return m_entries; } + Q_INVOKABLE void setEntries(const QVariantList &entries) { m_entries = entries; emit editorChanged(); } +signals: + void editorChanged(); +private: + int m_size = 100; + QVariantList m_entries = logitune::ringSlotsToJson(logitune::defaultRingSlots()).toVariantList(); +}; Q_IMPORT_QML_PLUGIN(LogitunePlugin) @@ -33,6 +48,7 @@ public slots: engine->rootContext()->setContextProperty("ActionModel", &m_actionModel); engine->rootContext()->setContextProperty("ProfileModel", &m_profileModel); engine->rootContext()->setContextProperty("SettingsModel", &m_settingsModel); + engine->rootContext()->setContextProperty("ActionsRing", &m_ringEditor); m_deviceModel.setDisplayValues(1000, true, 50, true, false, "scroll"); } @@ -43,6 +59,7 @@ public slots: logitune::ActionModel m_actionModel; logitune::ProfileModel m_profileModel; logitune::SettingsModel m_settingsModel; + RingEditorStub m_ringEditor; }; QUICK_TEST_MAIN_WITH_SETUP(logitune_qml, QmlTestSetup) diff --git a/tests/services/test_button_action_dispatcher.cpp b/tests/services/test_button_action_dispatcher.cpp index a33100d3..03c5519b 100644 --- a/tests/services/test_button_action_dispatcher.cpp +++ b/tests/services/test_button_action_dispatcher.cpp @@ -1,4 +1,5 @@ #include "services/ButtonActionDispatcherFixture.h" +#include using namespace logitune; using namespace logitune::test; @@ -20,6 +21,37 @@ void setProfileButton(Profile &p, int idx, ButtonAction ba) { } // namespace +TEST_F(ButtonActionDispatcherFixture, RingReleaseSurvivesProfileChange) { + attachMockSession(); + setProfileButton(hwProfile(), 3, {ButtonAction::ActionsRing, {}}); + QSignalSpy ring(m_dispatcher.get(), &ButtonActionDispatcher::actionsRingTriggered); + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + setProfileButton(hwProfile(), 3, {ButtonAction::Default, {}}); + m_dispatcher->onDivertedButtonPressed(kBackCid, false); + ASSERT_EQ(ring.count(), 2); + EXPECT_EQ(ring[0][0].toString(), "mock-serial"); + EXPECT_TRUE(ring[0][1].toBool()); + EXPECT_FALSE(ring[1][1].toBool()); +} + +TEST_F(ButtonActionDispatcherFixture, RingUsesEmittingDeviceWithoutCarouselSelection) { + attachMockSession(); + setProfileButton(hwProfile(), 3, {ButtonAction::ActionsRing, {}}); + m_deviceModel->setSelectedIndex(-1); + QSignalSpy ring(m_dispatcher.get(), &ButtonActionDispatcher::actionsRingTriggered); + m_dispatcher->dispatchButton(m_session, "mock-serial", &m_device, kBackCid, true); + m_dispatcher->dispatchButton(m_session, "mock-serial", &m_device, kBackCid, false); + ASSERT_EQ(ring.count(), 2); + EXPECT_EQ(ring[0][0].toString(), "mock-serial"); +} + +TEST_F(ButtonActionDispatcherFixture, UnrelatedReleaseDoesNotTriggerRing) { + attachMockSession(); + QSignalSpy ring(m_dispatcher.get(), &ButtonActionDispatcher::actionsRingTriggered); + m_dispatcher->onDivertedButtonPressed(0, false); + EXPECT_EQ(ring.count(), 0); +} + // --- Gesture accumulation + resolution -------------------------------------- TEST_F(ButtonActionDispatcherFixture, NullSessionGestureIsNoOp) { diff --git a/tests/test_actions_ring.cpp b/tests/test_actions_ring.cpp new file mode 100644 index 00000000..8e637ceb --- /dev/null +++ b/tests/test_actions_ring.cpp @@ -0,0 +1,152 @@ +#include +#include "ActionsRing.h" +#include "ProfileEngine.h" +#include "hidpp/capabilities/HapticsCapability.h" +#include +#include "services/ProfileOrchestratorFixture.h" +#include "services/ActionsRingService.h" +#include "DeviceManager.h" +#include +#include "input/UinputInjector.h" +#include + +using namespace logitune; + +TEST(ActionsRing, TapAndHoldBoundary) { + ActionsRingTrigger trigger; + EXPECT_EQ(trigger.press(100), ActionsRingTrigger::Open); + EXPECT_EQ(trigger.release(600), ActionsRingTrigger::None); + EXPECT_EQ(trigger.press(700), ActionsRingTrigger::Close); + trigger.closed(); + EXPECT_EQ(trigger.press(1000), ActionsRingTrigger::Open); + EXPECT_EQ(trigger.release(1501), ActionsRingTrigger::Select); + EXPECT_EQ(trigger.release(1600), ActionsRingTrigger::None); +} + +TEST(ActionsRing, DefaultsUseExecutableKeystrokes) { + for (const auto &entry : defaultRingSlots()) { + auto check = [](const RingSlot &slot) { + if (slot.action.type == ButtonAction::Keystroke || slot.action.type == ButtonAction::Media) + EXPECT_FALSE(ActionExecutor::parseKeystroke(slot.action.payload).empty()) + << slot.action.payload.toStdString(); + }; + check(entry); + for (const auto &child : entry.children) check(child); + } +} + +TEST(ActionsRing, AppLaunchPassesArgumentsWithoutShellExpansion) { + QTemporaryDir dir; + const auto path = dir.filePath("literal $HOME argument"); + UinputInjector injector; + injector.launchApp("/usr/bin/touch \"" + path + "\""); + QElapsedTimer timeout; + timeout.start(); + while (!QFileInfo::exists(path) && timeout.elapsed() < 1000) QTest::qWait(10); + EXPECT_TRUE(QFileInfo::exists(path)); +} + +TEST(ActionsRing, ProfileRoundTrip) { + QTemporaryDir dir; + Profile profile; + ASSERT_EQ(profile.ringSlots.size(), 8); + profile.ringSlots[0].name = "Custom"; + profile.ringSlots[0].action = {ButtonAction::Keystroke, "Ctrl+Shift+T"}; + profile.ringSize = 150; + const auto path = dir.filePath("ring.conf"); + ProfileEngine::saveProfile(path, profile); + const auto loaded = ProfileEngine::loadProfile(path); + ASSERT_EQ(loaded.ringSlots.size(), 8); + EXPECT_EQ(loaded.ringSlots[0].name, "Custom"); + EXPECT_EQ(loaded.ringSlots[0].action, profile.ringSlots[0].action); + EXPECT_EQ(loaded.ringSlots[4].children.size(), 3); + EXPECT_EQ(loaded.ringSize, 150); +} + +TEST(ActionsRing, LimitsAndNoNestedSubmenus) { + RingSlot slot; + slot.children.resize(12); + slot.children[0].children.resize(2); + QList entries(12, slot); + const auto loaded = ringSlotsFromJson(ringSlotsToJson(entries)); + ASSERT_EQ(loaded.size(), 8); + ASSERT_EQ(loaded[0].children.size(), 8); + EXPECT_TRUE(loaded[0].children[0].children.isEmpty()); +} + +TEST(Haptics, WaveformBytes) { + const auto &variant = hidpp::capabilities::kHapticsVariants[0]; + EXPECT_EQ(static_cast(variant.feature), 0x19b0); + EXPECT_EQ(variant.playFn, 4); + EXPECT_EQ(variant.buildPlay(4), (std::vector{4})); + EXPECT_EQ(variant.buildPlay(1), (std::vector{1})); +} + +using logitune::test::ProfileOrchestratorFixture; + +TEST_F(ProfileOrchestratorFixture, RingEditorPersistsDisplayedProfileOnly) { + attachMockSession(); + DeviceManager devices(nullptr); + ActionsRingService service(m_profileEngine.get(), &devices, m_executor.get(), + m_selection.get(), m_desktop.get(), m_orchestrator.get()); + m_profileEngine->createProfileForApp("mock-serial", "editor", "Editor"); + m_profileEngine->setDisplayProfile("mock-serial", "Editor"); + auto entries = service.entries(); + auto first = entries[0].toMap(); + first["name"] = "Profile specific"; + entries[0] = first; + service.setEntries(entries); + service.setSize(80); + EXPECT_EQ(service.entries()[0].toMap()["name"].toString(), "Profile specific"); + EXPECT_EQ(service.size(), 80); + const auto &hardware = m_profileEngine->cachedProfile("mock-serial", "default"); + EXPECT_NE(hardware.ringSlots[0].name, "Profile specific"); + EXPECT_EQ(hardware.ringSize, 100); +} + +TEST_F(ProfileOrchestratorFixture, RingExecutesOnlyAfterOverlayCloses) { + attachMockSession(); + DeviceManager devices(nullptr); + ActionsRingService service(m_profileEngine.get(), &devices, m_executor.get(), + m_selection.get(), m_desktop.get(), m_orchestrator.get()); + service.run("keystroke:Ctrl+C"); + EXPECT_TRUE(m_injector->lastArg("injectKeystroke").isEmpty()); + service.finished(); + QTest::qWait(80); + EXPECT_EQ(m_injector->lastArg("injectKeystroke"), "Ctrl+C"); + service.run("actions-ring"); + service.finished(); + QTest::qWait(80); + EXPECT_EQ(m_injector->lastArg("injectKeystroke"), "Ctrl+C"); +} + +TEST_F(ProfileOrchestratorFixture, RingButtonMigratesOnceAndPreservesExplicitDefault) { + attachMockSession(); + ControlDescriptor control; + control.controlId = 0x01a0; + control.buttonIndex = 8; + control.defaultName = "Haptic button"; + control.defaultActionType = "actions-ring"; + control.configurable = true; + m_device.m_controls.append(control); + const auto directory = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + + "/devices/mock-serial/profiles"; + QDir().mkpath(directory); + Profile original; + original.ringSlots[0].name = "Existing ring edit"; + ProfileEngine::saveProfile(directory + "/default.conf", original); + m_orchestrator->setupProfileForDevice(m_physical); + auto &migrated = m_profileEngine->cachedProfile("mock-serial", "default"); + EXPECT_EQ(migrated.buttons[8].type, ButtonAction::ActionsRing); + EXPECT_TRUE(migrated.ringButtonMigrated); + EXPECT_EQ(migrated.ringSlots[0].name, "Existing ring edit"); + m_orchestrator->onDisplayProfileChanged("mock-serial", migrated); + EXPECT_EQ(m_buttonModel->actionTypeForButton(8), "actions-ring"); + EXPECT_EQ(m_buttonModel->actionNameForButton(8), "Actions Ring"); + migrated.buttons[8] = {}; + m_profileEngine->saveProfileToDisk("mock-serial", "default"); + m_orchestrator->setupProfileForDevice(m_physical); + EXPECT_EQ(m_profileEngine->cachedProfile("mock-serial", "default").buttons[8].type, + ButtonAction::Default); + EXPECT_EQ(m_buttonModel->rowCount(), 9); +} diff --git a/tests/test_button_action.cpp b/tests/test_button_action.cpp index 0068a2fb..79e3fef2 100644 --- a/tests/test_button_action.cpp +++ b/tests/test_button_action.cpp @@ -4,6 +4,12 @@ using logitune::ButtonAction; +TEST(ButtonAction, ActionsRingRoundTrip) { + const auto action = ButtonAction::parse("actions-ring"); + EXPECT_NE(action.type, ButtonAction::Default); + EXPECT_EQ(action.serialize(), "actions-ring"); +} + // --------------------------------------------------------------------------- // Parse tests // --------------------------------------------------------------------------- diff --git a/tests/test_button_features.cpp b/tests/test_button_features.cpp index 67341e7b..4689198d 100644 --- a/tests/test_button_features.cpp +++ b/tests/test_button_features.cpp @@ -5,6 +5,27 @@ using namespace logitune::hidpp; using namespace logitune::hidpp::features; +TEST(ReprogControls, HeldButtonsTrackIndividualReleases) { + DivertedButtons buttons; + Report report; + report.paramLength = 8; + report.params[0] = 1; report.params[1] = 0xa0; + report.params[2] = 0; report.params[3] = 0x53; + auto events = buttons.update(report); + ASSERT_EQ(events.size(), 2); + EXPECT_EQ(events[0], (std::pair{0x1a0, true})); + EXPECT_TRUE(buttons.update(report).empty()); + report.params[0] = 0; report.params[1] = 0x53; + report.params[2] = 0; report.params[3] = 0; + events = buttons.update(report); + ASSERT_EQ(events.size(), 1); + EXPECT_EQ(events[0], (std::pair{0x1a0, false})); + report.params[1] = 0; + events = buttons.update(report); + ASSERT_EQ(events.size(), 1); + EXPECT_EQ(events[0], (std::pair{0x53, false})); +} + // --------------------------------------------------------------------------- // ReprogControls // --------------------------------------------------------------------------- diff --git a/tests/test_device_registry.cpp b/tests/test_device_registry.cpp index 5c8f974b..ed8c623f 100644 --- a/tests/test_device_registry.cpp +++ b/tests/test_device_registry.cpp @@ -72,8 +72,8 @@ static const DeviceSpec kDevices[] = { .pid = 0xb042, .name = "MX Master 4", .minDpi = 200, .maxDpi = 8000, .dpiStep = 50, - .buttonHotspots = 6, .scrollHotspots = 3, - .minControls = 7, + .buttonHotspots = 7, .scrollHotspots = 3, + .minControls = 9, .control0Cid = 0x0050, .control5Cid = 0x00C3, .control5ActionType = "gesture-trigger", .control6ActionType = "smartshift-toggle", @@ -211,6 +211,17 @@ TEST_P(DeviceRegistryTest, FeatureSupport) { EXPECT_EQ(f.smartShift, s.smartShift); EXPECT_EQ(f.reprogControls, s.reprogControls); EXPECT_EQ(f.gestureV2, s.gestureV2); + EXPECT_EQ(f.hapticFeedback, s.pid == 0xb042); +} + +TEST_P(DeviceRegistryTest, ActionsRingControl) { + if (GetParam().pid != 0xb042) return; + const auto controls = reg.findByPid(GetParam().pid)->controls(); + ASSERT_GE(controls.size(), 9); + EXPECT_EQ(controls[8].controlId, 0x01a0); + EXPECT_EQ(controls[8].defaultName, "Haptic button"); + EXPECT_EQ(controls[8].defaultActionType, "actions-ring"); + EXPECT_TRUE(controls[8].configurable); } TEST_P(DeviceRegistryTest, DpiRange) { diff --git a/tests/test_single_instance.cpp b/tests/test_single_instance.cpp index f073af2e..9ac7d6b2 100644 --- a/tests/test_single_instance.cpp +++ b/tests/test_single_instance.cpp @@ -9,6 +9,20 @@ #include "SingleInstance.h" +TEST(SingleInstanceTest, SecondaryRequestsRingWithoutMainWindow) +{ + QTemporaryDir dir; + const QString serverName = "logitune-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + logitune::SingleInstance primary(dir.filePath("instance.lock"), serverName); + ASSERT_FALSE(primary.start().has_value()); + QSignalSpy ring(&primary, &logitune::SingleInstance::ringRequested); + QSignalSpy shown(&primary, &logitune::SingleInstance::showRequested); + logitune::SingleInstance secondary(dir.filePath("instance.lock"), serverName); + EXPECT_EQ(secondary.start(logitune::SingleInstance::Command::Ring), std::optional(0)); + ASSERT_TRUE(ring.wait(2000)); + EXPECT_EQ(shown.count(), 0); +} + TEST(SingleInstanceTest, SecondaryRequestsShowAndExitsSuccessfully) { QTemporaryDir dir; From 94371cdc653f1ad72e6b9c1b6ee0be54d223dfcd Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Wed, 4 Nov 2026 01:01:08 +0000 Subject: [PATCH 4/7] feat(actions-ring): full-page ring editor, custom commands and volume adjuster Replace the form-style ring editor with a page built around a live ring preview and an action library: drag an action onto a bubble (or select a bubble and click one), swap bubbles, add submenu children, undo/redo, per-application rings from the profile strip, and the ring size under the cog. Labels sit beside their bubbles; submenu children's labels go outward. New actions: Custom command (any shell command, run through sh, with its own name and icon) and Volume, an adjuster you hover and scroll on the ring (click mutes). Adjusters store up/down/read/press commands in the slot and read the level only after each change finishes. Icons can be Nerd Font glyphs, theme icon names, or image files including animated GIFs, picked from a themed in-app browser with thumbnails. Open URL adds https:// when the address has no scheme. ActionModel.get() and ProfileModel.wmClassAt() replace raw role numbers in QML. --- src/app/CMakeLists.txt | 11 + src/app/main.cpp | 3 + src/app/models/ActionModel.cpp | 13 + src/app/models/ActionModel.h | 3 + src/app/models/InstalledAppsModel.cpp | 82 +++++ src/app/models/InstalledAppsModel.h | 20 ++ src/app/models/ProfileModel.cpp | 7 + src/app/models/ProfileModel.h | 2 + src/app/qml/DeviceView.qml | 3 +- src/app/qml/Main.qml | 10 +- src/app/qml/components/ActionsPanel.qml | 47 ++- src/app/qml/components/AppProfileBar.qml | 12 +- src/app/qml/components/actionsring/Glyph.qml | 31 +- src/app/qml/components/actionsring/Ring.qml | 130 +++++++- .../actionsring/RingActionLibrary.qml | 153 ++++++++++ .../qml/components/actionsring/RingEditor.qml | 160 ++++++---- .../actionsring/RingEditorBubble.qml | 56 ++++ .../actionsring/RingEditorState.qml | 119 ++++++++ .../actionsring/RingFileBrowser.qml | 286 ++++++++++++++++++ .../components/actionsring/RingFlatButton.qml | 36 +++ .../components/actionsring/RingGeometry.js | 8 + .../components/actionsring/RingIconPicker.qml | 125 ++++++++ .../actionsring/RingParameterCard.qml | 174 +++++++++++ .../components/actionsring/RingPreview.qml | 123 ++++++++ .../components/actionsring/RingSlotEditor.qml | 76 ++--- .../components/actionsring/RingTextField.qml | 24 ++ src/app/qml/pages/ButtonsPage.qml | 4 +- src/app/services/ActionsRingService.cpp | 28 ++ src/app/services/ActionsRingService.h | 6 + src/core/ActionsRing.cpp | 5 +- src/core/ActionsRing.h | 5 + tests/CMakeLists.txt | 1 + tests/qml/tst_actions_ring.qml | 17 +- tests/qml/tst_qml_main.cpp | 4 + tests/qml/tst_ring_adjuster.qml | 114 +++++++ tests/qml/tst_ring_editor_state.qml | 91 ++++++ tests/qml/tst_ring_file_browser.qml | 56 ++++ tests/qml/tst_ring_library.qml | 28 ++ tests/qml/tst_ring_preview.qml | 37 +++ tests/test_action_model.cpp | 12 + tests/test_actions_ring.cpp | 19 ++ tests/test_installed_apps_model.cpp | 68 +++++ tests/test_profile_model.cpp | 7 + 43 files changed, 2074 insertions(+), 142 deletions(-) create mode 100644 src/app/models/InstalledAppsModel.cpp create mode 100644 src/app/models/InstalledAppsModel.h create mode 100644 src/app/qml/components/actionsring/RingActionLibrary.qml create mode 100644 src/app/qml/components/actionsring/RingEditorBubble.qml create mode 100644 src/app/qml/components/actionsring/RingEditorState.qml create mode 100644 src/app/qml/components/actionsring/RingFileBrowser.qml create mode 100644 src/app/qml/components/actionsring/RingFlatButton.qml create mode 100644 src/app/qml/components/actionsring/RingGeometry.js create mode 100644 src/app/qml/components/actionsring/RingIconPicker.qml create mode 100644 src/app/qml/components/actionsring/RingParameterCard.qml create mode 100644 src/app/qml/components/actionsring/RingPreview.qml create mode 100644 src/app/qml/components/actionsring/RingTextField.qml create mode 100644 tests/qml/tst_ring_adjuster.qml create mode 100644 tests/qml/tst_ring_editor_state.qml create mode 100644 tests/qml/tst_ring_file_browser.qml create mode 100644 tests/qml/tst_ring_library.qml create mode 100644 tests/qml/tst_ring_preview.qml create mode 100644 tests/test_installed_apps_model.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 8fe48e29..6a93baf7 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(logitune-app-lib STATIC models/ActionModel.cpp models/ProfileModel.cpp models/EditorModel.cpp + models/InstalledAppsModel.cpp services/ActiveDeviceResolver.cpp services/DeviceCommandHandler.cpp services/ButtonActionDispatcher.cpp @@ -83,6 +84,16 @@ qt_add_qml_module(logitune-app-lib qml/components/actionsring/Glyph.qml qml/components/actionsring/RingEditor.qml qml/components/actionsring/RingSlotEditor.qml + qml/components/actionsring/RingEditorState.qml + qml/components/actionsring/RingActionLibrary.qml + qml/components/actionsring/RingPreview.qml + qml/components/actionsring/RingEditorBubble.qml + qml/components/actionsring/RingGeometry.js + qml/components/actionsring/RingParameterCard.qml + qml/components/actionsring/RingTextField.qml + qml/components/actionsring/RingFlatButton.qml + qml/components/actionsring/RingIconPicker.qml + qml/components/actionsring/RingFileBrowser.qml qml/components/DeviceCard.qml qml/components/DeviceRender.qml qml/components/AppProfileBar.qml diff --git a/src/app/main.cpp b/src/app/main.cpp index 6113d936..6be522ac 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,5 @@ #include +#include "models/InstalledAppsModel.h" #include #include #include @@ -207,6 +208,8 @@ int main(int argc, char *argv[]) QObject::connect(&app, &QCoreApplication::aboutToQuit, controller.actionsRing(), &logitune::ActionsRingService::shutdown); engine.rootContext()->setContextProperty("ActionsRing", controller.actionsRing()); + logitune::InstalledAppsModel installedApps; + engine.rootContext()->setContextProperty("InstalledApps", &installedApps); QObject::connect(&singleInstance, &logitune::SingleInstance::ringRequested, controller.actionsRing(), &logitune::ActionsRingService::toggle); diff --git a/src/app/models/ActionModel.cpp b/src/app/models/ActionModel.cpp index 1986640c..bce92091 100644 --- a/src/app/models/ActionModel.cpp +++ b/src/app/models/ActionModel.cpp @@ -176,4 +176,17 @@ ButtonAction ActionModel::buttonEntryToAction(const QString &actionType, const Q return {ButtonAction::Default, {}}; } +QVariantMap ActionModel::get(int row) const +{ + if (row < 0 || row >= rowCount()) + return {}; + const QModelIndex idx = index(row); + return { + {QStringLiteral("name"), data(idx, NameRole)}, + {QStringLiteral("type"), data(idx, ActionTypeRole)}, + {QStringLiteral("payload"), data(idx, PayloadRole)}, + {QStringLiteral("category"), data(idx, CategoryRole)}, + }; +} + } // namespace logitune diff --git a/src/app/models/ActionModel.h b/src/app/models/ActionModel.h index 443f6c0c..a37e5f03 100644 --- a/src/app/models/ActionModel.h +++ b/src/app/models/ActionModel.h @@ -39,6 +39,9 @@ class ActionModel : public QAbstractListModel { Q_INVOKABLE int indexForName(const QString &name) const; Q_INVOKABLE QString payloadForName(const QString &name) const; + // One row by field name, for QML: it can't see the Roles enum, and raw role numbers break + // silently if the roles are ever reordered. Empty for an out-of-range row. + Q_INVOKABLE QVariantMap get(int row) const; /// Translate a ButtonAction (domain type) to the (typeName, displayName) /// pair the UI uses. Inverse of buttonEntryToAction. diff --git a/src/app/models/InstalledAppsModel.cpp b/src/app/models/InstalledAppsModel.cpp new file mode 100644 index 00000000..1868ca2b --- /dev/null +++ b/src/app/models/InstalledAppsModel.cpp @@ -0,0 +1,82 @@ +#include "InstalledAppsModel.h" +#include +#include +#include +#include +#include +#include +#include + +namespace logitune { + +InstalledAppsModel::InstalledAppsModel(QObject *parent) : QAbstractListModel(parent) +{ + QStringList dirs; + for (const auto &dir : QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation)) + dirs << dir + "/applications"; + dirs << QDir::homePath() + "/.local/share/flatpak/exports/share/applications" + << "/var/lib/flatpak/exports/share/applications" + << "/var/lib/snapd/desktop/applications" << "/run/host/usr/share/applications"; + reload(dirs); +} + +int InstalledAppsModel::rowCount(const QModelIndex &parent) const { return parent.isValid() ? 0 : m_apps.size(); } +QHash InstalledAppsModel::roleNames() const +{ + return {{Qt::UserRole + 1, "name"}, {Qt::UserRole + 2, "icon"}, + {Qt::UserRole + 3, "command"}, {Qt::UserRole + 4, "desktopId"}}; +} +QVariant InstalledAppsModel::data(const QModelIndex &index, int role) const +{ + return get(index.row()).value(QString::fromUtf8(roleNames().value(role))); +} +QVariantMap InstalledAppsModel::get(int row) const +{ + return row >= 0 && row < m_apps.size() ? m_apps[row] : QVariantMap{}; +} + +void InstalledAppsModel::reload(const QStringList &directories, const QString &locale) +{ + QList apps; + QSet seen; + const QString language = locale.isEmpty() ? QLocale().name() : locale; + for (const auto &dir : directories) { + QDirIterator it(dir, {"*.desktop"}, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + QString id = QDir(dir).relativeFilePath(path).replace('/', '-'); + if (seen.contains(id)) continue; + seen.insert(id); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) continue; + QHash fields; + bool entry = false; + while (!file.atEnd()) { + QString line = QString::fromUtf8(file.readLine()).trimmed(); + if (line.startsWith('[')) { entry = line == "[Desktop Entry]"; continue; } + if (!entry || line.startsWith('#')) continue; + int equals = line.indexOf('='); + if (equals > 0) fields.insert(line.left(equals), line.mid(equals + 1)); + } + if (fields.value("Hidden") == "true" || fields.value("NoDisplay") == "true" + || fields.value("Type") != "Application" || fields.value("Exec").isEmpty()) continue; + QString tryExec = fields.value("TryExec"); + if (!tryExec.isEmpty() && QStandardPaths::findExecutable(tryExec).isEmpty()) continue; + QString name = fields.value("Name[" + language + "]", + fields.value("Name[" + language.section('_', 0, 0) + "]", fields.value("Name"))); + name.replace("\\s", " ").replace("\\n", " ").replace("\\\\", "\\"); + if (name.isEmpty()) continue; + // Delegate desktop field codes, quoting, Terminal and DBus activation to GIO. + QString quoted = path; + quoted.replace("\"", "\"\"\""); + apps.append({{"name", name}, {"icon", fields.value("Icon")}, {"desktopId", id}, + {"command", "gio launch \"" + quoted + "\""}}); + } + } + std::sort(apps.begin(), apps.end(), [](const auto &a, const auto &b) { + return QString::localeAwareCompare(a.value("name").toString(), b.value("name").toString()) < 0; + }); + beginResetModel(); m_apps = apps; endResetModel(); +} + +} // namespace logitune diff --git a/src/app/models/InstalledAppsModel.h b/src/app/models/InstalledAppsModel.h new file mode 100644 index 00000000..b05200ad --- /dev/null +++ b/src/app/models/InstalledAppsModel.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +namespace logitune { + +class InstalledAppsModel : public QAbstractListModel { + Q_OBJECT +public: + explicit InstalledAppsModel(QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + Q_INVOKABLE QVariantMap get(int row) const; + void reload(const QStringList &directories, const QString &locale = {}); +private: + QList m_apps; +}; + +} // namespace logitune diff --git a/src/app/models/ProfileModel.cpp b/src/app/models/ProfileModel.cpp index e2293ec5..749b1097 100644 --- a/src/app/models/ProfileModel.cpp +++ b/src/app/models/ProfileModel.cpp @@ -183,4 +183,11 @@ void ProfileModel::setHwActiveByProfileName(const QString &profileName) setHwActiveIndex(targetIndex); } +QString ProfileModel::wmClassAt(int row) const +{ + if (row < 0 || row >= rowCount()) + return {}; + return data(index(row), WmClassRole).toString(); +} + } // namespace logitune diff --git a/src/app/models/ProfileModel.h b/src/app/models/ProfileModel.h index 9b927321..e3c0fd48 100644 --- a/src/app/models/ProfileModel.h +++ b/src/app/models/ProfileModel.h @@ -27,6 +27,8 @@ class ProfileModel : public QAbstractListModel { int displayIndex() const; Q_INVOKABLE void addProfile(const QString &wmClass, const QString &name, const QString &icon = QString()); + // The profile's key for QML, which can't see the Roles enum. Empty for an out-of-range row. + Q_INVOKABLE QString wmClassAt(int row) const; Q_INVOKABLE void removeProfile(int index); Q_INVOKABLE void selectTab(int index); // user click -> emits profileSwitched diff --git a/src/app/qml/DeviceView.qml b/src/app/qml/DeviceView.qml index 06144066..14397b6c 100644 --- a/src/app/qml/DeviceView.qml +++ b/src/app/qml/DeviceView.qml @@ -5,6 +5,7 @@ import Logitune Item { id: root + signal configureRingRequested() RowLayout { anchors.fill: parent @@ -120,7 +121,7 @@ Item { // ButtonsPage component (default) Component { id: buttonsPageComponent - ButtonsPage {} + ButtonsPage { onConfigureRingRequested: root.configureRingRequested() } } // Point & Scroll page component diff --git a/src/app/qml/Main.qml b/src/app/qml/Main.qml index 1994942e..e20909d0 100644 --- a/src/app/qml/Main.qml +++ b/src/app/qml/Main.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Window import Logitune +import "components/actionsring" ApplicationWindow { id: root @@ -262,7 +263,14 @@ ApplicationWindow { onSettingsClicked: mainStack.push(appSettingsComponent) } } - Component { id: deviceViewComponent; DeviceView {} } + Component { + id: deviceViewComponent + DeviceView { onConfigureRingRequested: mainStack.push(ringEditorComponent) } + } + Component { + id: ringEditorComponent + RingEditor { onBackRequested: mainStack.pop() } + } Component { id: appSettingsComponent AppSettingsView { onQuitRequested: Qt.quit() } diff --git a/src/app/qml/components/ActionsPanel.qml b/src/app/qml/components/ActionsPanel.qml index ea662bca..b5435653 100644 --- a/src/app/qml/components/ActionsPanel.qml +++ b/src/app/qml/components/ActionsPanel.qml @@ -16,6 +16,7 @@ Rectangle { property bool isWheel: false // true for thumb wheel — shows wheel-specific modes signal closed() + signal configureRingRequested() signal actionSelected(string actionName, string actionType) signal wheelModeSelected(string mode) // "scroll", "zoom", "volume" @@ -517,9 +518,53 @@ Rectangle { } // ── Contextual component definitions ─────────────────────────────────── + // The loader stretches its item to fill the panel, so the content sits in a column pinned to + // the top; a bare Button here grew into a huge, unthemed block. Component { id: ringComponent - RingEditor {} + + Item { + implicitHeight: ringCol.implicitHeight + 24 + width: parent ? parent.width : 340 + + Column { + id: ringCol + anchors { + left: parent.left; right: parent.right + top: parent.top + leftMargin: 20; rightMargin: 20; topMargin: 12 + } + spacing: 12 + + Text { + width: parent.width + text: "Pressing this button opens the Actions Ring at your cursor. Choose what's on it, per application." + font.pixelSize: 12 + color: Theme.textSecondary + wrapMode: Text.WordWrap + } + + Rectangle { + id: configureButton + width: parent.width + height: 40 + radius: 8 + color: configureHover.hovered ? Theme.accentHover : Theme.accent + + Text { + anchors.centerIn: parent + text: "Configure Actions Ring" + font.pixelSize: 13 + font.bold: true + color: Theme.activeTabText + } + HoverHandler { id: configureHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.configureRingRequested() } + Accessible.role: Accessible.Button + Accessible.name: "Configure Actions Ring" + } + } + } } Component { diff --git a/src/app/qml/components/AppProfileBar.qml b/src/app/qml/components/AppProfileBar.qml index 1a18a592..cbf4baff 100644 --- a/src/app/qml/components/AppProfileBar.qml +++ b/src/app/qml/components/AppProfileBar.qml @@ -6,6 +6,7 @@ import Logitune Item { id: profileBar implicitHeight: 48 + property bool compact: false property var _allApps: [] @@ -47,7 +48,7 @@ Item { model: ProfileModel delegate: Rectangle { - width: chipLabel.implicitWidth + (model.icon ? 46 : 24) + width: profileBar.compact && model.index > 0 ? 36 : chipLabel.implicitWidth + (model.icon ? 46 : 24) height: 32 radius: 6 color: model.isActive ? Theme.accent @@ -66,14 +67,15 @@ Item { Image { width: 16; height: 16 anchors.verticalCenter: parent.verticalCenter - source: model.icon ? "image://icon/" + model.icon : "" + source: model.index === 0 && profileBar.compact ? "" : model.icon ? "image://icon/" + model.icon : "" sourceSize: Qt.size(16, 16) visible: status === Image.Ready } Text { id: chipLabel - text: model.name + text: profileBar.compact && model.index === 0 ? "All applications" : model.name + visible: !profileBar.compact || model.index === 0 || !model.icon font.pixelSize: 12 font.bold: model.isActive color: model.isActive ? Theme.activeTabText : Theme.text @@ -106,6 +108,8 @@ Item { } } } + ToolTip.visible: chipHover.hovered && profileBar.compact + ToolTip.text: model.name } } } @@ -180,7 +184,7 @@ Item { Text { id: addLabel anchors.centerIn: parent - text: "+ Add application" + text: profileBar.compact ? "+" : "+ Add application" font.pixelSize: 12 color: Theme.textSecondary } diff --git a/src/app/qml/components/actionsring/Glyph.qml b/src/app/qml/components/actionsring/Glyph.qml index 9fde6978..32c07b03 100644 --- a/src/app/qml/components/actionsring/Glyph.qml +++ b/src/app/qml/components/actionsring/Glyph.qml @@ -25,7 +25,7 @@ Item { Text { id: label text: themeIcon.visible ? "?" : glyph.icon - visible: !themeIcon.visible || themeIcon.status === Image.Error + visible: !glyph.isGif && (!themeIcon.visible || themeIcon.status === Image.Error) color: glyph.color font.family: "JetBrainsMono Nerd Font" font.pixelSize: glyph.pixelSize @@ -34,13 +34,38 @@ Item { y: (glyph.height - ink.tightBoundingRect.height) / 2 - ink.tightBoundingRect.y - fontMetrics.ascent } + // An icon is one of three things: a Nerd Font glyph (drawn as text above), a freedesktop theme + // icon name such as "firefox", or an absolute path to an image file the user picked. + readonly property bool isImageFile: glyph.icon.startsWith("/") || glyph.icon.startsWith("file:") + readonly property bool isThemeName: /^[a-zA-Z][a-zA-Z0-9._-]+$/.test(glyph.icon) + readonly property bool isGif: glyph.isImageFile && /\.gif$/i.test(glyph.icon) + readonly property string fileUrl: glyph.icon.startsWith("file:") ? glyph.icon : "file://" + glyph.icon + + // A plain Image would only show a GIF's first frame. + AnimatedImage { + anchors.centerIn: parent + width: glyph.pixelSize + height: glyph.pixelSize + visible: glyph.isGif + source: visible ? glyph.fileUrl : "" + playing: visible + fillMode: Image.PreserveAspectFit + } + Image { id: themeIcon anchors.centerIn: parent width: glyph.pixelSize height: glyph.pixelSize - visible: /^[a-zA-Z][a-zA-Z0-9-]+$/.test(glyph.icon) - source: visible ? "image://icon/" + glyph.icon : "" + visible: (glyph.isThemeName || glyph.isImageFile) && !glyph.isGif + source: { + if (glyph.isImageFile) + return glyph.fileUrl + if (glyph.isThemeName) + return "image://icon/" + glyph.icon + return "" + } sourceSize: Qt.size(width, height) + fillMode: Image.PreserveAspectFit } } diff --git a/src/app/qml/components/actionsring/Ring.qml b/src/app/qml/components/actionsring/Ring.qml index 0c1a5405..85696c53 100644 --- a/src/app/qml/components/actionsring/Ring.qml +++ b/src/app/qml/components/actionsring/Ring.qml @@ -1,5 +1,6 @@ import QtQuick import Logitune +import "RingGeometry.js" as Geometry // Sizes and timings follow ~/re/logioptionsplus/findings/02-ring-behaviour.md. Styling is our own. Item { @@ -42,18 +43,16 @@ Item { function slotAngle(index) { // First slot at 12 o'clock, then clockwise in 45 degree steps. - return index * 45 - 90 + return Geometry.rootAngle(index) } function childAngle(parentIndex, childIndex, childCount) { // Children are 22.5 degrees apart, as a group centred on the parent's angle. - let offset = (childIndex - (childCount - 1) / 2) * 22.5 - return slotAngle(parentIndex) + offset + return Geometry.childAngle(parentIndex, childIndex, childCount) } function pointOnCircle(radius, angleDegrees) { - let angle = angleDegrees * Math.PI / 180 - return Qt.point(radius * Math.cos(angle), radius * Math.sin(angle)) + return Geometry.point(angleDegrees, radius) } function angleBetween(firstDegrees, secondDegrees) { @@ -182,9 +181,86 @@ Item { expandedParent = -1 } + // The slot or submenu child under the pointer, or null. + function hoveredEntry() { + if (hoveredChild >= 0) + return expandedChildren[hoveredChild] + if (hovered >= 0) + return slotList[hovered] + return null + } + + function isAdjuster(entry) { + return entry !== null && entry !== undefined && entry.adjust !== undefined + } + + // Level shown on an adjuster's label: a percentage, or -1 while unknown. + property int level: -1 + property bool levelMuted: false + property real wheelRemainder: 0 + + function refreshLevel() { + let entry = hoveredEntry() + if (isAdjuster(entry) && entry.adjust.read) + launcher.readLevel(entry.adjust.read) + } + + // Turns a command's output into a percentage. Handles wpctl ("Volume: 0.45 [MUTED]", a 0..1 + // fraction) and anything that prints a plain number or "45%". + function parseLevel(output) { + let match = output.match(/(\d*\.?\d+)\s*(%?)/) + if (!match) + return {percent: -1, muted: false} + let value = parseFloat(match[1]) + let isFraction = match[2] === "" && match[1].indexOf(".") >= 0 && value <= 1.5 + let percent = Math.round(isFraction ? value * 100 : value) + return {percent: percent, muted: /mute/i.test(output)} + } + + Connections { + target: root.launcher + function onLevelRead(output) { + let parsed = root.parseLevel(output) + root.level = parsed.percent + root.levelMuted = parsed.muted + } + } + + // One step per notch of the wheel (120 units); smooth-scrolling mice send smaller deltas, + // so the remainder carries over between events. + function scrollAdjuster(delta) { + let entry = hoveredEntry() + if (!isAdjuster(entry)) + return + // Turning back the other way starts fresh; otherwise the leftover from the old direction + // has to be scrolled off first and the first step back feels late. + let reversed = delta * wheelRemainder < 0 + if (reversed) + wheelRemainder = 0 + wheelRemainder += delta + while (Math.abs(wheelRemainder) >= 120) { + let goingUp = wheelRemainder > 0 + // The service reads the level once the change has finished, so the label never shows + // the value from one step earlier. + launcher.adjust(goingUp ? entry.adjust.up : entry.adjust.down, entry.adjust.read || "") + wheelRemainder -= goingUp ? 120 : -120 + } + } + + onHoveredChanged: { level = -1; wheelRemainder = 0; refreshLevel() } + onHoveredChildChanged: { level = -1; wheelRemainder = 0; refreshLevel() } + function choose(slot, child) { if (closing) return + // Clicking an adjuster runs its press command (mute, for volume) and keeps the ring open, + // so you can keep scrolling. + let entry = child >= 0 ? slotList[slot].children[child] : slotList[slot] + if (slot >= 0 && isAdjuster(entry)) { + if (entry.adjust.press) + launcher.adjust(entry.adjust.press, entry.adjust.read || "") + return + } if (child >= 0) { launcher.run(slotList[slot].children[child].action) dismiss() @@ -253,6 +329,18 @@ Item { } } + // Scrolling over an adjuster changes its level. Vertical wheel up raises it; the MX Master's + // thumb wheel sends horizontal deltas, and scrolling it forward raises it too. + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + if (!root.isPlaced) + return + let delta = event.angleDelta.y !== 0 ? event.angleDelta.y : -event.angleDelta.x + root.scrollAdjuster(delta) + } + } + Item { id: ring x: root.hubX @@ -385,13 +473,22 @@ Item { id: legend // Slots that open a submenu get no legend; their children say what's inside. - readonly property string label: { + readonly property string name: { if (root.hoveredChild >= 0) return root.expandedChildren[root.hoveredChild].name if (root.hovered >= 0 && !root.hasChildren(root.hovered)) return root.slotList[root.hovered].name return "" } + // Adjusters show their live level and appear at once: you're about to scroll, and + // waiting 800 ms to see the value would defeat the point. + readonly property bool forAdjuster: root.isAdjuster(root.hoveredEntry()) + readonly property string levelText: { + if (root.levelMuted) + return "muted" + return root.level >= 0 ? root.level + "%" : "" + } + readonly property string label: forAdjuster && levelText !== "" ? name + " " + levelText : name readonly property point anchorCentre: { if (root.hoveredChild >= 0) return root.pointOnCircle(root.childRadius, root.childAngle(root.expandedParent, @@ -410,9 +507,14 @@ Item { Behavior on opacity { NumberAnimation { duration: 200 } } - onLabelChanged: { + // Keyed on the name, not the full label, so a changing level doesn't restart the delay. + onNameChanged: { shown = false - if (label !== "") + if (name === "") + return + if (forAdjuster) + shown = true + else legendDelay.restart() } @@ -452,6 +554,18 @@ Item { height: legendText.implicitHeight + 8 radius: height / 2 color: root.theme.surfaceRaised + clip: true + + // Level fill behind the text, for adjusters. + Rectangle { + visible: legend.forAdjuster && root.level >= 0 && !root.levelMuted + width: parent.width * Math.min(root.level, 100) / 100 + height: parent.height + radius: parent.radius + color: root.theme.accent + opacity: 0.35 + Behavior on width { NumberAnimation { duration: 90 } } + } Text { id: legendText diff --git a/src/app/qml/components/actionsring/RingActionLibrary.qml b/src/app/qml/components/actionsring/RingActionLibrary.qml new file mode 100644 index 00000000..7bde46ee --- /dev/null +++ b/src/app/qml/components/actionsring/RingActionLibrary.qml @@ -0,0 +1,153 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +ColumnLayout { + id: root + property string query: "" + property var actions: [] + property var collapsed: ({}) + readonly property var filtered: actions.filter(a => (a.name + " " + a.group).toLowerCase().indexOf(query.toLowerCase()) >= 0) + signal chosen(var entry) + + function glyph(name, group) { + let icons = {Copy: "󰆏", Cut: "", Paste: "󰆒", Undo: "", Redo: "", Mute: "󰝟", + "Volume up": "󰝝", "Volume down": "󰝞", "Play/Pause": "󰐎", "Next track": "󰒭", + "Previous track": "󰒮", "Back": "", "Forward": "", "Screenshot": "󰹑"} + return icons[name] || ({Media: "󰎆", System: "󰒓", Window: "", Workspace: "󰍹", Edit: "󰏫"}[group] || "󰌌") + } + function populate() { + let result = [] + for (let i = 0; i < ActionModel.rowCount(); ++i) { + let row = ActionModel.get(i) + let type = row.type + if (["keystroke", "media", "preset", "app-launch", "dbus"].indexOf(type) < 0) continue + let name = row.name + let payload = row.payload + let group = row.category + result.push({name: name, group: group, icon: glyph(name, group), + action: type + ":" + payload, kind: type === "keystroke" && !payload ? "keystroke" : ""}) + } + result = result.concat([ + {name: "Open application", group: "Open", kind: "app", icon: "󰀻"}, + {name: "Open URL", group: "Open", kind: "url", icon: "󰖟"}, + {name: "Open file", group: "Open", kind: "file", icon: "󰈔"}, + {name: "Open folder", group: "Open", kind: "folder", icon: "󰉋"}, + {name: "Custom command", group: "Open", kind: "command", icon: "\uf120"}, + // An adjuster: hover it on the ring and scroll. PipeWire's wpctl; -l 1.0 caps at 100%. + {name: "Volume", group: "Media", icon: "\uf028", action: "default", adjust: { + up: "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+", + down: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + read: "wpctl get-volume @DEFAULT_AUDIO_SINK@", + press: "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"}}, + {name: "Submenu", group: "Organize", children: [], icon: "󰕰"}]) + // The list draws a section header wherever the group changes, so entries of one group + // must be adjacent. Keep groups in the order they first appear. + let groupOrder = [] + for (let entry of result) { + if (groupOrder.indexOf(entry.group) < 0) + groupOrder.push(entry.group) + } + let grouped = [] + for (let group of groupOrder) + grouped = grouped.concat(result.filter(entry => entry.group === group)) + actions = grouped + } + Component.onCompleted: populate() + Connections { + target: ActionModel + function onModelReset() { root.populate() } + function onRowsInserted() { root.populate() } + function onRowsRemoved() { root.populate() } + } + spacing: 12 + Label { text: "All actions"; color: Theme.text; font { pixelSize: 20; bold: true } } + RingTextField { + objectName: "actionSearch" + Layout.fillWidth: true + placeholderText: "Search actions" + text: root.query + onTextEdited: root.query = text + } + Label { + Layout.fillWidth: true + text: "Drag onto a bubble, or select a bubble and click an action." + wrapMode: Text.WordWrap; color: Theme.textSecondary; font.pixelSize: 12 + } + ListView { + id: list + Layout.fillWidth: true; Layout.fillHeight: true + clip: true; model: root.filtered + section.property: "group" + section.criteria: ViewSection.FullString + // Drawn by hand: a stock ItemDelegate uses Qt's light default style and ignores the app theme. + section.delegate: Rectangle { + id: header + required property string section + width: list.width + height: 36 + color: headerHover.hovered ? Theme.hoverBg : "transparent" + radius: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + x: 8 + text: (root.collapsed[header.section] && !root.query ? "▸ " : "▾ ") + header.section + color: Theme.textSecondary + font.pixelSize: 12 + font.bold: true + font.capitalization: Font.AllUppercase + } + HoverHandler { id: headerHover } + TapHandler { + onTapped: { + let value = Object.assign({}, root.collapsed) + value[header.section] = !value[header.section] + root.collapsed = value + } + } + } + delegate: Item { + id: row + required property var modelData + width: list.width + height: root.collapsed[modelData.group] && !root.query ? 0 : 44 + visible: height > 0 + Rectangle { + id: tile + width: row.width; height: 42; radius: 6 + color: hover.hovered || drag.active ? Theme.hoverBg : Theme.cardBg + border.color: Theme.border + property var ringSource: ({entry: row.modelData}) + Drag.active: drag.active + Drag.keys: ["ring-action"] + Drag.hotSpot: Qt.point(width / 2, height / 2) + Drag.source: tile + z: drag.active ? 100 : 0 + RowLayout { + anchors.fill: parent; anchors.margins: 8; spacing: 12 + Item { Layout.preferredWidth: 26; Layout.fillHeight: true + Glyph { icon: row.modelData.icon; color: Theme.text; pixelSize: 20 } + } + Label { text: row.modelData.name; color: Theme.text; Layout.fillWidth: true; elide: Text.ElideRight } + } + HoverHandler { id: hover } + TapHandler { onTapped: root.chosen(row.modelData) } + DragHandler { + id: drag + onActiveChanged: { + if (active) { + let point = tile.mapToItem(Overlay.overlay, 0, 0) + tile.parent = Overlay.overlay; tile.x = point.x; tile.y = point.y + } else { + tile.Drag.drop(); tile.parent = row; tile.x = 0; tile.y = 0 + } + } + } + } + } + ScrollBar.vertical: ScrollBar {} + } + Label { visible: root.filtered.length === 0; text: "No matching actions"; color: Theme.textSecondary } +} diff --git a/src/app/qml/components/actionsring/RingEditor.qml b/src/app/qml/components/actionsring/RingEditor.qml index 6be57cfa..0467a72c 100644 --- a/src/app/qml/components/actionsring/RingEditor.qml +++ b/src/app/qml/components/actionsring/RingEditor.qml @@ -3,81 +3,117 @@ import QtQuick.Controls import QtQuick.Layouts import Logitune -ScrollView { +Item { id: root - property var entries: ActionsRing.entries - property int selectedSlot: 0 - clip: true - contentWidth: availableWidth + property alias editorState: state + readonly property var entries: state.entries + property alias selectedSlot: state.selectedSlot + signal backRequested() - function save(index, entry) { - let copy = entries.slice() - copy[index] = entry - ActionsRing.setEntries(copy) + function save(index, entry) { state.select(index, -1); state.assign(entry) } + function chooseAction(entry) { + if (entry.kind) state.beginParameter(entry) + else state.assign(entry) + } + function reloadProfile() { + let key = ProfileModel.wmClassAt(ProfileModel.displayIndex) || "default" + state.loadProfile(key, ActionsRing.entries, ActionsRing.size) + } + Component.onCompleted: reloadProfile() + Connections { + target: ProfileModel + // Backend profile switching completes synchronously before the next event turn. + function onDisplayIndexChanged() { Qt.callLater(root.reloadProfile) } + function onProfileRemoved(wmClass) { delete state.histories[wmClass] } + } + RingEditorState { + id: state + onCommitted: (value, ringSize) => { + ActionsRing.setEntries(value) + ActionsRing.setSize(ringSize) + } } ColumnLayout { - width: root.availableWidth - spacing: 10 - Label { text: "Actions Ring"; color: Theme.text; font.bold: true } + anchors.fill: parent; anchors.margins: 20; spacing: 16 RowLayout { - Label { text: "Size"; color: Theme.text } - ComboBox { - model: ["80%", "100%", "150%"] - currentIndex: [80, 100, 150].indexOf(ActionsRing.size) - onActivated: ActionsRing.size = [80, 100, 150][currentIndex] - } - } - ComboBox { - id: selected - Layout.fillWidth: true - model: root.entries.map((entry, index) => (index + 1) + ". " + entry.name) - currentIndex: root.selectedSlot - onActivated: root.selectedSlot = currentIndex - } - RingSlotEditor { Layout.fillWidth: true - visible: root.selectedSlot >= 0 - entry: root.entries[root.selectedSlot] || ({}) - allowSubmenu: true - onEdited: (value) => root.save(root.selectedSlot, value) + RingFlatButton { text: "← Back"; onClicked: root.backRequested() } + AppProfileBar { Layout.fillWidth: true; Layout.preferredHeight: 48; compact: true } + Item { Layout.preferredWidth: 80 } } - Repeater { - model: (root.entries[root.selectedSlot] || {}).children || [] - ColumnLayout { - id: childRow - required property int index - required property var modelData + Label { text: "Actions Ring"; color: Theme.text; font { pixelSize: 24; bold: true } } + RowLayout { + Layout.fillWidth: true; Layout.fillHeight: true; spacing: 20 + // Fills the space so the ring's hub sits in the middle of the visible area. + RingPreview { Layout.fillWidth: true - Label { text: "Child " + (childRow.index + 1); color: Theme.textSecondary } - RingSlotEditor { - Layout.fillWidth: true - entry: childRow.modelData - onEdited: (value) => { - let parent = Object.assign({}, root.entries[root.selectedSlot]) - parent.children = parent.children.slice() - parent.children[childRow.index] = value - root.save(root.selectedSlot, parent) + Layout.fillHeight: true + clip: true + editor: state + } + Rectangle { Layout.fillHeight: true; width: 1; color: Theme.border } + ScrollView { + Layout.preferredWidth: root.width < 900 ? 250 : 300 + Layout.fillHeight: true; clip: true; contentWidth: availableWidth + ColumnLayout { + width: parent.width; spacing: 20 + RingParameterCard { + Layout.fillWidth: true + visible: state.pending !== null + editor: state } - } - Button { - text: "Remove child" - onClicked: { - let parent = Object.assign({}, root.entries[root.selectedSlot]) - parent.children = parent.children.filter((entry, i) => i !== childRow.index) - if (!parent.children.length) delete parent.children - root.save(root.selectedSlot, parent) + RingSlotEditor { Layout.fillWidth: true; visible: state.pending === null; editor: state } + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.border } + RingActionLibrary { + objectName: "actionLibrary" + Layout.fillWidth: true; Layout.preferredHeight: 500 + onChosen: entry => root.chooseAction(entry) } } } } - Button { - text: "Add child" - visible: ((root.entries[root.selectedSlot] || {}).children || []).length > 0 - enabled: ((root.entries[root.selectedSlot] || {}).children || []).length < 8 - onClicked: { - let parent = Object.assign({}, root.entries[root.selectedSlot]) - parent.children = parent.children.concat([{icon: "", name: "New action", action: "default"}]) - root.save(root.selectedSlot, parent) + RowLayout { + Layout.fillWidth: true + RingFlatButton { text: "↶ Undo"; enabled: state.canUndo; onClicked: state.undo() } + RingFlatButton { text: "↷ Redo"; enabled: state.canRedo; onClicked: state.redo() } + Label { text: "Saved automatically"; color: Theme.textSecondary; font.pixelSize: 12 } + Item { Layout.fillWidth: true } + RingFlatButton { + text: "⚙"; Accessible.name: "Ring size settings" + onClicked: settings.open() + Menu { + id: settings + y: -height + background: Rectangle { + implicitWidth: 160 + radius: 8 + color: Theme.cardBg + border.color: Theme.border + } + Repeater { + model: [80, 100, 150] + MenuItem { + id: sizeItem + required property int modelData + text: "Ring size " + modelData + "%" + checkable: true + checked: state.size === modelData + onTriggered: state.changeSize(modelData) + contentItem: Text { + text: sizeItem.text + color: sizeItem.checked ? Theme.accent : Theme.text + font.pixelSize: 13 + font.bold: sizeItem.checked + verticalAlignment: Text.AlignVCenter + leftPadding: 8 + } + background: Rectangle { + radius: 6 + color: sizeItem.highlighted ? Theme.hoverBg : "transparent" + } + } + } + } } } } diff --git a/src/app/qml/components/actionsring/RingEditorBubble.qml b/src/app/qml/components/actionsring/RingEditorBubble.qml new file mode 100644 index 00000000..7bb6e1aa --- /dev/null +++ b/src/app/qml/components/actionsring/RingEditorBubble.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import Logitune + +Item { + id: root + required property var editor + required property int slot + property int child: -1 + required property var entry + property real bubbleSize: 48 + readonly property bool selected: editor.selectedSlot === slot && editor.selectedChild === child + width: bubbleSize; height: bubbleSize + objectName: "bubble-" + slot + "-" + child + DropArea { + anchors.fill: parent + keys: ["ring-action"] + onDropped: drop => { + if (drop.source && drop.source.ringSource) + drop.accepted = root.editor.drop(drop.source.ringSource, root.slot, root.child) + } + RingButton { + id: button + centre: Qt.point(root.width / 2, root.height / 2) + size: root.bubbleSize + iconSize: size * 0.42 + icon: root.entry.icon || "+" + hasSubmenu: root.entry.children !== undefined + border.color: root.selected || parent.containsDrag ? Theme.accent : Theme.border + border.width: root.selected || parent.containsDrag ? 3 : 1 + theme: ({surface: Theme.cardBg, accent: Theme.accent, text: Theme.text, + onAccent: Theme.activeTabText, border: Theme.border}) + property var ringSource: ({slot: root.slot, child: root.child}) + Drag.active: drag.active + Drag.source: button + Drag.keys: ["ring-action"] + Drag.hotSpot: Qt.point(width / 2, height / 2) + TapHandler { onTapped: root.editor.select(root.slot, root.child) } + DragHandler { + id: drag + enabled: root.entry.action !== undefined || root.entry.children !== undefined + onActiveChanged: { + if (active) { + let layer = Overlay.overlay || root.Window.window.contentItem + let point = button.mapToItem(layer, 0, 0) + button.parent = layer; button.x = point.x; button.y = point.y + } else { + button.Drag.drop(); button.parent = root.children[0] + button.x = 0; button.y = 0 + } + } + } + } + } +} diff --git a/src/app/qml/components/actionsring/RingEditorState.qml b/src/app/qml/components/actionsring/RingEditorState.qml new file mode 100644 index 00000000..db36acd2 --- /dev/null +++ b/src/app/qml/components/actionsring/RingEditorState.qml @@ -0,0 +1,119 @@ +import QtQuick + +QtObject { + id: root + property var entries: [] + property int size: 100 + property int selectedSlot: 0 + property int selectedChild: -1 + property string profileKey: "" + property var histories: ({}) + property var past: [] + property var future: [] + property var pending: null + readonly property bool canUndo: past.length > 0 + readonly property bool canRedo: future.length > 0 + readonly property var selectedEntry: entryAt(selectedSlot, selectedChild) || ({}) + signal committed(var value, int ringSize) + + function clone(value) { return JSON.parse(JSON.stringify(value)) } + function snapshot() { return {entries: clone(entries), size: size} } + function loadProfile(key, value, ringSize) { + if (profileKey) histories[profileKey] = {past: past, future: future} + profileKey = key + entries = clone(value) + size = ringSize + let history = histories[key] || {past: [], future: []} + past = history.past; future = history.future + select(0, -1) + pending = null + } + function select(slot, child) { selectedSlot = slot; selectedChild = child } + function entryAt(slot, child) { + let entry = entries[slot] + return child < 0 ? entry : (entry && entry.children ? entry.children[child] : null) + } + function commit(value, ringSize) { + if (JSON.stringify(value) === JSON.stringify(entries) && ringSize === size) return + past = past.concat([snapshot()]); future = [] + entries = value; size = ringSize + committed(entries, size) + } + function put(value, slot, child, entry) { + if (slot < 0 || slot >= value.length || child >= 8) return false + if (child < 0) value[slot] = clone(entry) + else { + if (entry.children || !value[slot].children || child > value[slot].children.length) return false + value[slot].children[child] = clone(entry) + } + return true + } + function assign(entry) { + let value = clone(entries) + if (!put(value, selectedSlot, selectedChild, entry)) return false + commit(value, size) + return true + } + function drop(source, slot, child) { + if (source.entry) { + select(slot, child) + if (source.entry.kind) { beginParameter(source.entry); return true } + return assign(source.entry) + } + if (source.slot === slot && source.child === child) return false + // A root cannot be swapped into its own children, nor a submenu into any child. + if (source.slot === slot && (source.child < 0 || child < 0)) return false + let from = entryAt(source.slot, source.child) + let to = entryAt(slot, child) || {name: "", icon: "", action: "default"} + if (!from) return false + let value = clone(entries) + if (!put(value, slot, child, from) || !put(value, source.slot, source.child, to)) return false + commit(value, size); select(slot, child) + return true + } + function clear() { + let value = clone(entries) + if (selectedChild >= 0) value[selectedSlot].children.splice(selectedChild, 1) + else value[selectedSlot] = {name: "", icon: "", action: "default"} + commit(value, size); selectedChild = -1 + } + function rename(name) { let entry = clone(selectedEntry); entry.name = name; assign(entry) } + function setIcon(icon) { let entry = clone(selectedEntry); entry.icon = icon; assign(entry) } + function changeSize(value) { if ([80, 100, 150].indexOf(value) >= 0) commit(clone(entries), value) } + function undo() { + if (!canUndo) return + future = future.concat([snapshot()]) + let value = past[past.length - 1]; past = past.slice(0, -1) + entries = clone(value.entries); size = value.size; pending = null + selectedChild = -1; committed(entries, size) + } + function redo() { + if (!canRedo) return + past = past.concat([snapshot()]) + let value = future[future.length - 1]; future = future.slice(0, -1) + entries = clone(value.entries); size = value.size; pending = null + selectedChild = -1; committed(entries, size) + } + function beginParameter(action) { pending = {action: clone(action), slot: selectedSlot, child: selectedChild} } + function cancelParameter() { pending = null } + function saveParameter(payload, name, icon) { + if (!pending || !payload.trim()) return false + let action = pending.action + let kind = action.kind === "keystroke" ? "keystroke" : "app-launch" + // xdg-open treats "claude.ai/new" as a file path and silently fails; a URL needs its scheme. + let hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(payload.trim()) + if (action.kind === "url" && !hasScheme) + payload = "https://" + payload.trim() + if (["url", "file", "folder"].indexOf(action.kind) >= 0) + payload = 'xdg-open "' + payload.replace(/"/g, '"""') + '"' + // The app-launch executor splits a command into words and runs it without a shell, so pipes, + // $(...) and ~ would not work. Hand custom commands to sh instead. Triple quotes are how + // QProcess::splitCommand reads a literal quote inside a quoted argument. + if (action.kind === "command") + payload = 'sh -c "' + payload.replace(/"/g, '"""') + '"' + select(pending.slot, pending.child) + let saved = assign({name: name || action.name, icon: icon || action.icon, action: kind + ":" + payload}) + if (saved) pending = null + return saved + } +} diff --git a/src/app/qml/components/actionsring/RingFileBrowser.qml b/src/app/qml/components/actionsring/RingFileBrowser.qml new file mode 100644 index 00000000..c28c006c --- /dev/null +++ b/src/app/qml/components/actionsring/RingFileBrowser.qml @@ -0,0 +1,286 @@ +import QtCore +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt.labs.folderlistmodel +import Logitune + +// In-app file browser for the ring editor. Qt's own non-native dialog is a light, unthemed list +// with no previews, which is useless for choosing an icon image. This one draws in the app theme +// and shows a thumbnail grid; GIFs animate while hovered. +// +// mode "image": pick an image file; "file": pick any file; "folder": pick the folder being shown. +Popup { + id: browser + + property string mode: "image" + property string folderPath: home + property string selectedPath: "" + + signal chosen(string path) + + readonly property string home: StandardPaths.writableLocation(StandardPaths.HomeLocation).toString() + .replace(/^file:\/\//, "") + readonly property var imageFilters: ["*.png", "*.svg", "*.jpg", "*.jpeg", "*.webp", "*.gif"] + readonly property var places: [ + { name: "Home", glyph: "", path: home }, + { name: "Pictures", glyph: "", path: home + "/Pictures" }, + { name: "Downloads", glyph: "", path: home + "/Downloads" }, + { name: "Icons", glyph: "", path: home + "/.local/share/icons" }, + { name: "System icons", glyph: "", path: "/usr/share/icons" }, + ] + + function isImage(name) { + return /\.(png|svg|jpe?g|webp|gif)$/i.test(name) + } + + function goUp() { + if (folderPath === "/") + return + let parentPath = folderPath.replace(/\/[^\/]+\/?$/, "") + folderPath = parentPath === "" ? "/" : parentPath + } + + function openFolder(path) { + folderPath = path + selectedPath = "" + } + + function confirm() { + let path = mode === "folder" ? folderPath : selectedPath + if (path === "") + return + chosen(path) + close() + } + + width: 720 + height: 520 + padding: 0 + modal: true + anchors.centerIn: Overlay.overlay + onOpened: selectedPath = "" + + background: Rectangle { + radius: 10 + color: Theme.cardBg + border.color: Theme.border + } + + FolderListModel { + id: folder + folder: "file://" + browser.folderPath + showDirsFirst: true + showDotAndDotDot: false + showHidden: false + nameFilters: browser.mode === "image" ? browser.imageFilters : ["*"] + showFiles: browser.mode !== "folder" + sortCaseSensitive: false + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + RingFlatButton { + text: "" + font.family: "JetBrainsMono Nerd Font" + enabled: browser.folderPath !== "/" + onClicked: browser.goUp() + ToolTip.visible: hovered + ToolTip.text: "Up one folder" + } + RingTextField { + id: pathField + Layout.fillWidth: true + text: browser.folderPath + font.family: "monospace" + // Typing a path and pressing Enter jumps straight there. + onAccepted: browser.openFolder(text.replace(/\/$/, "") || "/") + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + // Fixed width: its rows fill their column, and without a maximum the layout handed this + // column the whole dialog and squeezed the file grid to nothing. + ColumnLayout { + Layout.preferredWidth: 150 + Layout.minimumWidth: 150 + Layout.maximumWidth: 150 + Layout.fillWidth: false + Layout.fillHeight: true + spacing: 2 + + Repeater { + model: browser.places + Rectangle { + id: place + required property var modelData + readonly property bool current: browser.folderPath === modelData.path + Layout.fillWidth: true + height: 32 + radius: 6 + color: current ? Theme.hoverBg : placeHover.hovered ? Theme.hoverBg : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + Item { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Glyph { icon: place.modelData.glyph; pixelSize: 14; color: Theme.textSecondary } + } + Text { + text: place.modelData.name + color: place.current ? Theme.accent : Theme.text + font.pixelSize: 13 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + HoverHandler { id: placeHover } + TapHandler { onTapped: browser.openFolder(place.modelData.path) } + } + } + Item { Layout.fillHeight: true } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 8 + color: Theme.surface + clip: true + + GridView { + id: grid + objectName: "fileGrid" + anchors.fill: parent + anchors.margins: 8 + cellWidth: 108 + cellHeight: 112 + model: folder + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + id: tile + required property string fileName + required property string filePath + required property bool fileIsDir + readonly property bool selected: browser.selectedPath === filePath + readonly property bool showsPreview: !fileIsDir && browser.isImage(fileName) + readonly property bool isGif: /\.gif$/i.test(fileName) + + width: grid.cellWidth - 8 + height: grid.cellHeight - 8 + radius: 8 + color: selected ? Theme.accent : tileHover.hovered ? Theme.hoverBg : "transparent" + + Item { + id: preview + anchors.horizontalCenter: parent.horizontalCenter + y: 8 + width: 64 + height: 64 + + Glyph { + visible: !tile.showsPreview + icon: tile.fileIsDir ? "" : "" + pixelSize: 44 + color: tile.fileIsDir ? Theme.accent : Theme.textSecondary + } + // Thumbnails load off the UI thread and at a small size, so big photos + // don't stall scrolling. GIFs only play while the pointer is over them. + Image { + anchors.fill: parent + visible: tile.showsPreview && !tile.isGif + source: visible ? "file://" + tile.filePath : "" + asynchronous: true + sourceSize: Qt.size(128, 128) + fillMode: Image.PreserveAspectFit + } + AnimatedImage { + anchors.fill: parent + visible: tile.isGif + source: visible ? "file://" + tile.filePath : "" + asynchronous: true + playing: tileHover.hovered || tile.selected + fillMode: Image.PreserveAspectFit + } + } + + Text { + anchors.top: preview.bottom + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 8 + text: tile.fileName + color: tile.selected ? Theme.activeTabText : Theme.text + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideMiddle + } + + HoverHandler { id: tileHover } + TapHandler { + onTapped: { + if (!tile.fileIsDir) + browser.selectedPath = tile.filePath + } + onDoubleTapped: { + if (tile.fileIsDir) { + browser.openFolder(tile.filePath) + return + } + browser.selectedPath = tile.filePath + browser.confirm() + } + } + } + } + + Text { + anchors.centerIn: parent + visible: folder.status === FolderListModel.Ready && folder.count === 0 + text: browser.mode === "image" ? "No images in this folder" : "This folder is empty" + color: Theme.textSecondary + font.pixelSize: 13 + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: { + if (browser.mode === "folder") + return browser.folderPath + return browser.selectedPath || "Nothing selected" + } + color: Theme.textSecondary + font.pixelSize: 12 + elide: Text.ElideMiddle + } + RingFlatButton { text: "Cancel"; onClicked: browser.close() } + RingFlatButton { + text: browser.mode === "folder" ? "Use this folder" : "Choose" + primary: true + enabled: browser.mode === "folder" || browser.selectedPath !== "" + onClicked: browser.confirm() + } + } + } +} diff --git a/src/app/qml/components/actionsring/RingFlatButton.qml b/src/app/qml/components/actionsring/RingFlatButton.qml new file mode 100644 index 00000000..2957debf --- /dev/null +++ b/src/app/qml/components/actionsring/RingFlatButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls +import Logitune + +// A button in Logitune's colours. "primary" fills it with the accent, for the main action in a card. +Button { + id: control + + property bool primary: false + + implicitHeight: 32 + leftPadding: 12 + rightPadding: 12 + font.pixelSize: 13 + + contentItem: Text { + text: control.text + font: control.font + color: control.primary ? Theme.activeTabText : Theme.text + opacity: control.enabled ? 1 : 0.4 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 6 + color: { + if (control.primary) + return control.hovered ? Theme.accentHover : Theme.accent + return control.hovered ? Theme.hoverBg : Theme.cardBg + } + border.color: control.primary ? "transparent" : Theme.border + opacity: control.enabled ? 1 : 0.5 + } +} diff --git a/src/app/qml/components/actionsring/RingGeometry.js b/src/app/qml/components/actionsring/RingGeometry.js new file mode 100644 index 00000000..87e46591 --- /dev/null +++ b/src/app/qml/components/actionsring/RingGeometry.js @@ -0,0 +1,8 @@ +.pragma library + +function rootAngle(index) { return index * 45 - 90 } +function childAngle(parent, index, count) { return rootAngle(parent) + (index - (count - 1) / 2) * 22.5 } +function point(angle, radius) { + let radians = angle * Math.PI / 180 + return Qt.point(Math.cos(radians) * radius, Math.sin(radians) * radius) +} diff --git a/src/app/qml/components/actionsring/RingIconPicker.qml b/src/app/qml/components/actionsring/RingIconPicker.qml new file mode 100644 index 00000000..64cf1440 --- /dev/null +++ b/src/app/qml/components/actionsring/RingIconPicker.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +// Icon chooser for ring slots: a searchable grid of built-in Nerd Font glyphs, plus a free field +// that takes any glyph, a theme icon name ("firefox") or an image file. +Popup { + id: picker + + signal picked(string icon) + + property string query: "" + readonly property var icons: [ + ["copy clipboard", "󰆏"], ["paste clipboard", "󰆒"], ["cut scissors", "\uf0c4"], + ["undo back", "\uf0e2"], ["redo forward", "\uf01e"], ["keyboard shortcut", "󰌌"], + ["terminal command", "\uf120"], ["application grid", "󰀻"], ["browser web globe", "󰖟"], + ["folder directory", "󰉋"], ["file document", "󰈔"], ["menu submenu", "󰕰"], + ["play pause", "󰐎"], ["stop", "󰓛"], ["next skip", "󰒭"], ["previous", "󰒮"], + ["volume sound up", "󰝝"], ["volume sound down", "󰝞"], ["mute", "󰝟"], + ["music media", "󰎆"], ["headphones audio", "󰋋"], ["microphone", "󰍬"], + ["settings gear", "󰒓"], ["home", "\uf015"], ["search", "\uf002"], ["star favorite", "\uf005"], + ["heart", "\uf004"], ["bookmark", "\uf02e"], ["bell notification", "\uf0f3"], ["calendar", "\uf073"], + ["clock time", "\uf017"], ["camera screenshot", "󰹑"], ["display desktop", "󰍹"], + ["window", "\uf2d0"], ["close cross", "\uf00d"], ["lock security", "\uf023"], ["unlock", "\uf09c"], + ["sun brightness", "\uf185"], ["moon dark", "\uf186"], ["power", "\uf011"], ["refresh", "\uf021"], + ["left arrow", "\uf060"], ["right arrow", "\uf061"], ["up arrow", "\uf062"], ["down arrow", "\uf063"], + ["mail email", "\uf0e0"], ["chat message", "\uf075"], ["code", "\uf121"], ["save disk", "\uf0c7"], + ["download", "\uf019"], ["upload", "\uf093"], ["trash delete", "\uf1f8"], ["edit pencil", "󰏫"], + ["colour picker eyedropper", "󰈊"], ["wifi network", "󰖩"], ["bluetooth", "󰂯"], + ["git branch", "\ue725"], ["docker container", "\ue7b0"], ["game controller", "󰊗"]] + + function choose(icon) { + picked(icon) + close() + } + + width: 320 + height: 400 + padding: 12 + modal: true + onOpened: { + query = "" + search.text = "" + custom.text = "" + } + + background: Rectangle { + radius: 8 + color: Theme.cardBg + border.color: Theme.border + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + RingTextField { + id: search + Layout.fillWidth: true + placeholderText: "Search icons" + onTextChanged: picker.query = text.toLowerCase() + } + + GridView { + id: grid + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: 46 + cellHeight: 46 + model: picker.icons.filter(entry => entry[0].indexOf(picker.query) >= 0) + delegate: ItemDelegate { + required property var modelData + width: 42 + height: 42 + background: Rectangle { + radius: 6 + color: parent.hovered ? Theme.hoverBg : "transparent" + } + Glyph { icon: parent.modelData[1]; color: Theme.text; pixelSize: 22 } + ToolTip.visible: hovered + ToolTip.text: modelData[0] + onClicked: picker.choose(modelData[1]) + } + ScrollBar.vertical: ScrollBar {} + } + + Label { + text: "Or your own: paste a glyph, a theme icon name, or pick an image" + color: Theme.textSecondary + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + RingTextField { + id: custom + Layout.fillWidth: true + placeholderText: "e.g. 󰊠 or firefox" + onAccepted: if (text.trim()) picker.choose(text.trim()) + } + RingFlatButton { + text: "Image…" + onClicked: imageDialog.open() + } + RingFlatButton { + text: "Use" + primary: true + enabled: custom.text.trim().length > 0 + onClicked: picker.choose(custom.text.trim()) + } + } + } + + RingFileBrowser { + id: imageDialog + mode: "image" + onChosen: path => picker.choose(path) + } +} diff --git a/src/app/qml/components/actionsring/RingParameterCard.qml b/src/app/qml/components/actionsring/RingParameterCard.qml new file mode 100644 index 00000000..4dd5858e --- /dev/null +++ b/src/app/qml/components/actionsring/RingParameterCard.qml @@ -0,0 +1,174 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +Frame { + id: root + required property var editor + property string payload: "" + property string appName: "" + property string appIcon: "" + property var apps: [] + readonly property string kind: editor.pending ? editor.pending.action.kind : "" + function reset() { + payload = ""; appName = ""; appIcon = "" + value.text = ""; appSearch.text = ""; capture.keystroke = "" + commandText.text = ""; commandName.text = "" + } + Connections { target: root.editor; function onPendingChanged() { root.reset() } } + Component.onCompleted: { + let result = [] + if (typeof InstalledApps !== "undefined") + for (let i = 0; i < InstalledApps.rowCount(); ++i) result.push(InstalledApps.get(i)) + apps = result + } + background: Rectangle { color: Theme.cardBg; radius: 8; border.color: Theme.border } + ColumnLayout { + anchors.fill: parent; spacing: 12 + Label { + text: root.editor.pending ? root.editor.pending.action.name : "" + color: Theme.text; font { bold: true; pixelSize: 18 } + Layout.fillWidth: true; wrapMode: Text.WordWrap + } + RingTextField { + id: appSearch + visible: root.kind === "app"; Layout.fillWidth: true + placeholderText: "Search installed applications" + } + ListView { + id: appList + visible: root.kind === "app" + Layout.fillWidth: true; Layout.preferredHeight: 230 + clip: true + model: root.apps.filter(a => a.name.toLowerCase().indexOf(appSearch.text.toLowerCase()) >= 0) + delegate: Rectangle { + id: appRow + required property var modelData + readonly property bool chosen: root.payload === modelData.command + width: appList.width + height: 36 + radius: 6 + color: chosen ? Theme.accent : appHover.hovered ? Theme.hoverBg : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 10 + Item { + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + Glyph { icon: appRow.modelData.icon; pixelSize: 20; color: Theme.text } + } + Text { + text: appRow.modelData.name + color: appRow.chosen ? Theme.activeTabText : Theme.text + font.pixelSize: 13 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + HoverHandler { id: appHover } + TapHandler { + onTapped: { + root.payload = appRow.modelData.command + root.appName = appRow.modelData.name + root.appIcon = appRow.modelData.icon + } + } + } + ScrollBar.vertical: ScrollBar {} + } + Label { + visible: root.kind === "app"; text: root.appName || "Choose an application" + color: Theme.textSecondary; Layout.fillWidth: true; elide: Text.ElideRight + } + KeystrokeCapture { + id: capture + visible: root.kind === "keystroke"; Layout.fillWidth: true + onKeystrokeCaptured: ks => root.payload = ks + } + RingTextField { + id: value + objectName: "parameterValue" + Layout.fillWidth: true + visible: ["url", "file", "folder"].indexOf(root.kind) >= 0 + placeholderText: root.kind === "url" ? "https://…" : "Absolute path" + onTextChanged: root.payload = text + } + // Custom command: any shell command, with its own name and icon. + RingTextField { + id: commandText + objectName: "commandText" + Layout.fillWidth: true + visible: root.kind === "command" + placeholderText: "Command, e.g. grim -g \"$(slurp)\" - | wl-copy" + font.family: "monospace" + onTextChanged: root.payload = text + } + RingTextField { + id: commandName + objectName: "commandName" + Layout.fillWidth: true + visible: root.kind === "command" + placeholderText: "Name shown on the ring" + onTextChanged: root.appName = text + } + RowLayout { + visible: root.kind === "command" + spacing: 10 + + Rectangle { + width: 40 + height: 40 + radius: 20 + color: Theme.surface + border.color: Theme.border + Glyph { + icon: root.appIcon || "" + pixelSize: 20 + color: Theme.text + } + } + RingFlatButton { + text: "Choose icon…" + onClicked: commandIconPicker.open() + } + } + RingIconPicker { + id: commandIconPicker + onPicked: icon => root.appIcon = icon + } + + RingFlatButton { + visible: root.kind === "file" || root.kind === "folder" + text: root.kind === "folder" ? "Choose folder…" : "Choose file…" + onClicked: { + fileBrowser.mode = root.kind + fileBrowser.open() + } + } + RowLayout { + Layout.fillWidth: true + RingFlatButton { objectName: "parameterCancel"; text: "Cancel"; onClicked: root.editor.cancelParameter() } + Item { Layout.fillWidth: true } + RingFlatButton { + objectName: "parameterSave"; text: "Save"; primary: true; enabled: root.payload.trim().length > 0 + onClicked: { + let name = root.appName + if (!name && root.kind === "keystroke") name = root.payload + if (!name && root.kind === "url") name = root.payload.replace(/^https?:\/\//, "").replace(/\/$/, "") + if (!name && (root.kind === "file" || root.kind === "folder")) name = root.payload.split("/").filter(s => s).pop() + // A command with no name falls back to its program name, e.g. "grim". + if (!name && root.kind === "command") name = root.payload.trim().split(/\s+/)[0] + root.editor.saveParameter(root.payload, name, root.appIcon) + } + } + } + } + RingFileBrowser { + id: fileBrowser + onChosen: path => value.text = path + } +} diff --git a/src/app/qml/components/actionsring/RingPreview.qml b/src/app/qml/components/actionsring/RingPreview.qml new file mode 100644 index 00000000..84e6c825 --- /dev/null +++ b/src/app/qml/components/actionsring/RingPreview.qml @@ -0,0 +1,123 @@ +import QtQuick +import QtQuick.Controls +import Logitune +import "RingGeometry.js" as Geometry + +Item { + id: root + required property var editor + readonly property real sizeScale: editor.size / 100 + readonly property var targets: { + let result = [] + for (let i = 0; i < editor.entries.length; ++i) { + let p = Geometry.point(Geometry.rootAngle(i), 82 * sizeScale) + result.push({slot: i, child: -1, entry: editor.entries[i], x: p.x, y: p.y}) + } + let parent = editor.entries[editor.selectedSlot] + if (parent && parent.children !== undefined) { + let count = Math.min(8, parent.children.length + 1) + for (let j = 0; j < count; ++j) { + let p = Geometry.point(Geometry.childAngle(editor.selectedSlot, j, count), 182 * sizeScale) + result.push({slot: editor.selectedSlot, child: j, entry: parent.children[j] || {}, x: p.x, y: p.y}) + } + } + return result + } + // Each label sits right beside its own bubble on the side facing away from the hub, so no + // leader lines are needed. While a submenu is fanned out, main-slot labels pointing into the + // fan are hidden; they would sit on top of the child bubbles. + readonly property bool submenuOpen: targets.some(t => t.child >= 0) + readonly property var labels: { + let result = [] + for (let t of targets) { + let isMainSlot = t.child < 0 + let fanAngle = Geometry.rootAngle(editor.selectedSlot) + let slotAngle = Geometry.rootAngle(t.slot) + let pointsIntoFan = isMainSlot && submenuOpen && angleBetween(slotAngle, fanAngle) <= 70 + if (!pointsIntoFan) + result.push(t) + } + return result + } + + function angleBetween(first, second) { + let difference = Math.abs(first - second) % 360 + return difference > 180 ? 360 - difference : difference + } + Rectangle { + anchors.centerIn: parent + width: 32 * root.sizeScale; height: width; radius: width / 2 + color: Theme.surface; border.color: Theme.border + Glyph { icon: "󰍽"; pixelSize: 20 * root.sizeScale; color: Theme.textSecondary } + } + Repeater { + model: root.labels + Rectangle { + id: pill + required property var modelData + readonly property real gap: 24 * root.sizeScale + 8 + // Bubbles on the hub's vertical line get their label above or below; the rest to the side. + readonly property bool onVerticalLine: Math.abs(modelData.x) < 1 + readonly property bool onLeft: modelData.x < -1 + + // Submenu children sit only ~70 px apart, so a side label would run into the next + // child. Their labels go straight outward from the hub instead, and in a fan that runs + // across the top or bottom every other label is pushed further out so neighbours clear. + readonly property bool isChild: modelData.child >= 0 + readonly property real angle: Math.atan2(modelData.y, modelData.x) + readonly property bool fanIsHorizontal: Math.abs(Math.cos(angle)) < 0.5 + readonly property real stagger: isChild && fanIsHorizontal && modelData.child % 2 === 1 ? 30 : 0 + readonly property point outwardCentre: { + let reach = Math.hypot(modelData.x, modelData.y) + gap + stagger + // Push the pill's centre out by half its own size along the same direction, so its + // nearest edge (not its middle) sits next to the bubble. + let along = Math.abs(Math.cos(angle)) * width / 2 + Math.abs(Math.sin(angle)) * height / 2 + return Qt.point((reach + along) * Math.cos(angle), (reach + along) * Math.sin(angle)) + } + + width: Math.min(pillText.implicitWidth + 20, 150) + height: 26 + radius: height / 2 + x: { + if (isChild) + return root.width / 2 + outwardCentre.x - width / 2 + let centreX = root.width / 2 + modelData.x + if (onVerticalLine) + return centreX - width / 2 + return onLeft ? centreX - gap - width : centreX + gap + } + y: { + if (isChild) + return root.height / 2 + outwardCentre.y - height / 2 + let centreY = root.height / 2 + modelData.y + if (!onVerticalLine) + return centreY - height / 2 + return modelData.y < 0 ? centreY - gap - height : centreY + gap + } + color: Theme.cardBg + border.color: Theme.border + + Text { + id: pillText + anchors.centerIn: parent + width: Math.min(implicitWidth, 130) + text: pill.modelData.entry.name || "Add action" + elide: Text.ElideRight + color: pill.modelData.entry.name ? Theme.text : Theme.textSecondary + font.pixelSize: 12 + } + TapHandler { onTapped: root.editor.select(pill.modelData.slot, pill.modelData.child) } + } + } + Repeater { + model: root.targets + RingEditorBubble { + required property var modelData + editor: root.editor + slot: modelData.slot; child: modelData.child; entry: modelData.entry + bubbleSize: 48 * root.sizeScale + x: root.width / 2 + modelData.x - width / 2 + y: root.height / 2 + modelData.y - height / 2 + } + } +} diff --git a/src/app/qml/components/actionsring/RingSlotEditor.qml b/src/app/qml/components/actionsring/RingSlotEditor.qml index 5f704b5d..81779561 100644 --- a/src/app/qml/components/actionsring/RingSlotEditor.qml +++ b/src/app/qml/components/actionsring/RingSlotEditor.qml @@ -5,68 +5,26 @@ import Logitune ColumnLayout { id: root - required property var entry - property bool allowSubmenu: false - property var choices: [] - signal edited(var value) + required property var editor + spacing: 10 - function change(key, value) { - let copy = Object.assign({}, entry) - copy[key] = value - edited(copy) - } - Component.onCompleted: { - let list = [{name: "Choose an action…", action: "default"}] - for (let i = 0; i < ActionModel.rowCount(); i++) { - let index = ActionModel.index(i, 0) - let type = ActionModel.data(index, 259) - if (["keystroke", "media", "app-launch", "dbus", "preset"].indexOf(type) < 0) - continue - list.push({name: ActionModel.data(index, 257), action: type + ":" + ActionModel.data(index, 260)}) - } - choices = list - } - RowLayout { + Label { text: "Selected action"; color: Theme.text; font.bold: true } + + RingTextField { + objectName: "slotName" Layout.fillWidth: true - TextField { - Layout.preferredWidth: 100 - text: root.entry.icon || "" - placeholderText: "Icon / glyph" - color: Theme.text - onEditingFinished: root.change("icon", text) - } - TextField { - Layout.fillWidth: true - text: root.entry.name || "" - placeholderText: "Name" - color: Theme.text - onEditingFinished: root.change("name", text) - } - } - CheckBox { - visible: root.allowSubmenu - text: "Submenu" - checked: root.entry.children !== undefined - onToggled: { - let copy = Object.assign({}, root.entry) - if (checked) copy.children = [{icon: "", name: "New action", action: "default"}] - else delete copy.children - root.edited(copy) - } + text: root.editor.selectedEntry.name || "" + placeholderText: "Action name" + onEditingFinished: root.editor.rename(text) } - ComboBox { - Layout.fillWidth: true - visible: root.entry.children === undefined - model: root.choices - textRole: "name" - onActivated: root.change("action", root.choices[currentIndex].action) + + RowLayout { + RingFlatButton { text: "Choose icon…"; onClicked: picker.open() } + RingFlatButton { text: "Clear"; onClicked: root.editor.clear() } } - TextField { - Layout.fillWidth: true - visible: root.entry.children === undefined - text: root.entry.action || "default" - placeholderText: "keystroke:Ctrl+C / app-launch:kitty" - color: Theme.text - onEditingFinished: root.change("action", text) + + RingIconPicker { + id: picker + onPicked: icon => root.editor.setIcon(icon) } } diff --git a/src/app/qml/components/actionsring/RingTextField.qml b/src/app/qml/components/actionsring/RingTextField.qml new file mode 100644 index 00000000..6debee7d --- /dev/null +++ b/src/app/qml/components/actionsring/RingTextField.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtQuick.Controls +import Logitune + +// A text field in Logitune's colours. The default Qt style draws a white box that ignores the theme. +TextField { + id: field + + implicitHeight: 32 + leftPadding: 10 + rightPadding: 10 + color: Theme.text + placeholderTextColor: Theme.textSecondary + selectionColor: Theme.accent + selectedTextColor: Theme.activeTabText + font.pixelSize: 13 + + background: Rectangle { + radius: 6 + color: Theme.inputBg + border.color: field.activeFocus ? Theme.accent : "transparent" + border.width: 1 + } +} diff --git a/src/app/qml/pages/ButtonsPage.qml b/src/app/qml/pages/ButtonsPage.qml index 972b42f9..85eeaefb 100644 --- a/src/app/qml/pages/ButtonsPage.qml +++ b/src/app/qml/pages/ButtonsPage.qml @@ -11,6 +11,7 @@ import Logitune // ───────────────────────────────────────────────────────────────────────────── Item { id: root + signal configureRingRequested() // Currently selected button (-1 = none) property int selectedButton: -1 @@ -189,7 +190,8 @@ Item { // ── Actions Panel (per-button picker + thumb-wheel modes) ──────────────── ActionsPanel { - id: actionsPanel + id: actionsPanel + onConfigureRingRequested: root.configureRingRequested() anchors { top: parent.top diff --git a/src/app/services/ActionsRingService.cpp b/src/app/services/ActionsRingService.cpp index 31db1eb7..aac332fd 100644 --- a/src/app/services/ActionsRingService.cpp +++ b/src/app/services/ActionsRingService.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -165,6 +166,33 @@ void ActionsRingService::haptic(const QString &kind) if (kind == "hover") m_devices->playHaptic(m_serial, 0x04); } +void ActionsRingService::adjust(const QString &command, const QString &readCommand) +{ + if (command.trimmed().isEmpty()) return; + // Read the level only after the change has finished. Reading straight away raced the command + // and showed the value from one step earlier, so reversing direction looked like an extra step. + auto *process = new QProcess(this); + connect(process, &QProcess::finished, this, [this, process, readCommand]() { + process->deleteLater(); + readLevel(readCommand); + }); + // Through sh so user commands can use pipes and $(...), like custom commands do. + process->start(QStringLiteral("sh"), {QStringLiteral("-c"), command}); + // One light tick per step, so scrolling through the level can be felt. + m_devices->playHaptic(m_serial, 0x04); +} + +void ActionsRingService::readLevel(const QString &command) +{ + if (command.trimmed().isEmpty()) return; + auto *process = new QProcess(this); + connect(process, &QProcess::finished, this, [this, process]() { + emit levelRead(QString::fromUtf8(process->readAllStandardOutput()).trimmed()); + process->deleteLater(); + }); + process->start(QStringLiteral("sh"), {QStringLiteral("-c"), command}); +} + void ActionsRingService::run(const QString &action) { auto parsed = ButtonAction::parse(action); diff --git a/src/app/services/ActionsRingService.h b/src/app/services/ActionsRingService.h index 59afa468..cf9816d5 100644 --- a/src/app/services/ActionsRingService.h +++ b/src/app/services/ActionsRingService.h @@ -32,6 +32,11 @@ class ActionsRingService : public QObject { Q_INVOKABLE void dismiss(); Q_INVOKABLE void haptic(const QString &kind); Q_INVOKABLE void run(const QString &action); + // For adjuster slots, which act while the ring stays open (unlike run(), which waits for it + // to close). adjust() runs a shell command now and, once it has finished, the optional read + // command; readLevel() runs one in the background and reports its output through levelRead(). + Q_INVOKABLE void adjust(const QString &command, const QString &readCommand = QString()); + Q_INVOKABLE void readLevel(const QString &command); Q_INVOKABLE void placed(QObject *ring); Q_INVOKABLE void finished(); void shutdown(); @@ -41,6 +46,7 @@ class ActionsRingService : public QObject { Q_INVOKABLE void setEntries(const QVariantList &entries); signals: void editorChanged(); + void levelRead(const QString &output); private: void open(const QString &serial); void destroyOverlays(); diff --git a/src/core/ActionsRing.cpp b/src/core/ActionsRing.cpp index c271f433..88ca58c4 100644 --- a/src/core/ActionsRing.cpp +++ b/src/core/ActionsRing.cpp @@ -28,6 +28,8 @@ static QJsonArray encodeSlots(const QList &entries, bool children) {"action", slot.action.serialize()}}; if (children && !slot.children.isEmpty()) object.insert("children", encodeSlots(slot.children, false)); + if (!slot.adjust.isEmpty()) + object.insert("adjust", slot.adjust); array.append(object); } return array; @@ -43,7 +45,8 @@ static QList decodeSlots(const QJsonArray &array, bool children) if (!value.isObject()) continue; const auto object = value.toObject(); RingSlot slot{object.value("icon").toString(), object.value("name").toString(), - ButtonAction::parse(object.value("action").toString()), {}}; + ButtonAction::parse(object.value("action").toString()), {}, + object.value("adjust").toObject()}; if (children) slot.children = decodeSlots(object.value("children").toArray(), false); entries.append(slot); } diff --git a/src/core/ActionsRing.h b/src/core/ActionsRing.h index 9185e8cd..15f56f5e 100644 --- a/src/core/ActionsRing.h +++ b/src/core/ActionsRing.h @@ -1,6 +1,7 @@ #pragma once #include "ButtonAction.h" #include +#include #include namespace logitune { @@ -10,6 +11,10 @@ struct RingSlot { QString name; ButtonAction action; QList children; + // Set for an adjuster slot (scroll to change a level, e.g. volume): shell commands under + // "up", "down", "read" (prints the current level) and "press" (on click). Empty otherwise. + // Kept as raw JSON because only the ring overlay interprets it. + QJsonObject adjust; }; QList defaultRingSlots(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 153d7a3d..90234e5c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(logitune-tests test_button_model.cpp test_profile_model.cpp test_action_model.cpp + test_installed_apps_model.cpp test_device_model.cpp test_wmclass_resolution.cpp test_app_root.cpp diff --git a/tests/qml/tst_actions_ring.qml b/tests/qml/tst_actions_ring.qml index 90911f70..d0973f85 100644 --- a/tests/qml/tst_actions_ring.qml +++ b/tests/qml/tst_actions_ring.qml @@ -5,6 +5,7 @@ import "../../src/app/qml/components/actionsring" TestCase { name: "ActionsRing" when: windowShown + visible: true width: 800 height: 800 QtObject { @@ -61,7 +62,7 @@ TestCase { } Component { id: editorComponent; RingEditor {} } function test_editorLoadsAndEdits() { - let editor = createTemporaryObject(editorComponent, this, {width: 360, height: 500}) + let editor = createTemporaryObject(editorComponent, this, {width: 1000, height: 700}) verify(editor !== null) compare(editor.entries.length, 8) let entry = Object.assign({}, editor.entries[0]) @@ -75,4 +76,18 @@ TestCase { compare(editor.selectedSlot, 6) compare(editor.entries[6].name, "Edited undo") } + function test_editorClickAssignmentAndCancel() { + let editor = createTemporaryObject(editorComponent, this, {width: 1000, height: 700}) + verify(editor !== null) + verify(editor.editorState !== undefined) + editor.chooseAction({name: "Copy", icon: "C", action: "keystroke:Ctrl+C"}) + compare(editor.entries[0].action, "keystroke:Ctrl+C") + editor.chooseAction({name: "URL", icon: "U", kind: "url"}) + verify(editor.editorState.pending !== null) + let cancel = findChild(editor, "parameterCancel") + verify(cancel !== null) + mouseClick(cancel) + compare(editor.editorState.pending, null) + compare(editor.entries[0].action, "keystroke:Ctrl+C") + } } diff --git a/tests/qml/tst_qml_main.cpp b/tests/qml/tst_qml_main.cpp index e509030b..27d47989 100644 --- a/tests/qml/tst_qml_main.cpp +++ b/tests/qml/tst_qml_main.cpp @@ -8,6 +8,7 @@ #include "models/ActionModel.h" #include "models/ProfileModel.h" #include "models/SettingsModel.h" +#include "models/InstalledAppsModel.h" #include "logging/LogManager.h" #include "ActionsRing.h" @@ -18,6 +19,7 @@ class RingEditorStub : public QObject { public: QVariantList entries() const { return m_entries; } Q_INVOKABLE void setEntries(const QVariantList &entries) { m_entries = entries; emit editorChanged(); } + Q_INVOKABLE void setSize(int size) { m_size = size; emit editorChanged(); } signals: void editorChanged(); private: @@ -49,6 +51,7 @@ public slots: engine->rootContext()->setContextProperty("ProfileModel", &m_profileModel); engine->rootContext()->setContextProperty("SettingsModel", &m_settingsModel); engine->rootContext()->setContextProperty("ActionsRing", &m_ringEditor); + engine->rootContext()->setContextProperty("InstalledApps", &m_installedApps); m_deviceModel.setDisplayValues(1000, true, 50, true, false, "scroll"); } @@ -60,6 +63,7 @@ public slots: logitune::ProfileModel m_profileModel; logitune::SettingsModel m_settingsModel; RingEditorStub m_ringEditor; + logitune::InstalledAppsModel m_installedApps; }; QUICK_TEST_MAIN_WITH_SETUP(logitune_qml, QmlTestSetup) diff --git a/tests/qml/tst_ring_adjuster.qml b/tests/qml/tst_ring_adjuster.qml new file mode 100644 index 00000000..67ab1388 --- /dev/null +++ b/tests/qml/tst_ring_adjuster.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "RingAdjuster" + when: windowShown + visible: true + width: 800 + height: 800 + + QtObject { + id: bridge + property var commands: [] + property var reads: [] + property string chosen: "" + signal levelRead(string output) + function haptic(kind) {} + function run(action) { chosen = action } + property var readsAfterAdjust: [] + function adjust(command, readCommand) { + commands = commands.concat([command]) + readsAfterAdjust = readsAfterAdjust.concat([readCommand]) + } + function readLevel(command) { reads = reads.concat([command]) } + function placed(ring) {} + function finished() {} + } + + Ring { + id: ring + anchors.fill: parent + launcher: bridge + slotList: [ + {icon: "V", name: "Volume", action: "default", adjust: { + up: "vol-up", down: "vol-down", read: "vol-read", press: "vol-mute"}}, + {icon: "A", name: "Copy", action: "keystroke:Ctrl+C"}] + } + + function init() { + ring.hubX = 400 + ring.hubY = 400 + ring.isPlaced = true + ring.hovered = ring.noTarget + ring.hoveredChild = -1 + ring.closing = false + bridge.commands = [] + bridge.reads = [] + bridge.readsAfterAdjust = [] + bridge.chosen = "" + } + + function test_parsesWpctlAndPlainOutput() { + compare(ring.parseLevel("Volume: 0.57").percent, 57) + compare(ring.parseLevel("Volume: 0.40 [MUTED]").muted, true) + compare(ring.parseLevel("45%").percent, 45) + compare(ring.parseLevel("80").percent, 80) + compare(ring.parseLevel("no number here").percent, -1) + } + + function test_hoveringReadsTheLevel() { + ring.hovered = 0 + compare(bridge.reads, ["vol-read"]) + bridge.levelRead("Volume: 0.30") + compare(ring.level, 30) + } + + function test_eachNotchIsOneStep() { + ring.hovered = 0 + ring.scrollAdjuster(120) + ring.scrollAdjuster(-240) + compare(bridge.commands, ["vol-up", "vol-down", "vol-down"]) + } + + function test_smoothScrollingAddsUp() { + ring.hovered = 0 + ring.scrollAdjuster(40) + ring.scrollAdjuster(40) + compare(bridge.commands, []) + ring.scrollAdjuster(40) + compare(bridge.commands, ["vol-up"]) + } + + // Reversing must not need the leftover from the other direction worked off first. + function test_reversingDropsTheLeftover() { + ring.hovered = 0 + ring.scrollAdjuster(200) + compare(bridge.commands, ["vol-up"]) + ring.scrollAdjuster(-120) + compare(bridge.commands, ["vol-up", "vol-down"]) + } + + // The level is read after each change finishes, not raced against it. + function test_stepsReadTheLevelAfterwards() { + ring.hovered = 0 + bridge.reads = [] + ring.scrollAdjuster(120) + compare(bridge.readsAfterAdjust, ["vol-read"]) + compare(bridge.reads, []) + } + + function test_scrollOverNormalSlotDoesNothing() { + ring.hovered = 1 + ring.scrollAdjuster(120) + compare(bridge.commands, []) + } + + function test_clickMutesAndKeepsRingOpen() { + ring.choose(0, -1) + compare(bridge.commands, ["vol-mute"]) + compare(bridge.chosen, "") + verify(!ring.closing) + } +} diff --git a/tests/qml/tst_ring_editor_state.qml b/tests/qml/tst_ring_editor_state.qml new file mode 100644 index 00000000..f2546855 --- /dev/null +++ b/tests/qml/tst_ring_editor_state.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "RingEditorState" + Component { id: factory; RingEditorState {} } + property var state + function init() { + state = createTemporaryObject(factory, this) + state.loadProfile("global", Array.from({length: 8}, () => ({name: "", icon: "", action: "default"})), 100) + } + function test_assignmentAndHistory() { + state.select(2, -1) + state.assign({name: "Copy", icon: "C", action: "keystroke:Ctrl+C"}) + compare(state.entries[2].name, "Copy") + verify(state.canUndo) + state.undo() + compare(state.entries[2].action, "default") + state.redo() + compare(state.entries[2].action, "keystroke:Ctrl+C") + } + function test_customCommandRunsThroughShell() { + state.select(4, -1) + state.beginParameter({name: "Custom command", kind: "command", icon: ""}) + verify(state.saveParameter('grim -g "$(slurp)" - | wl-copy', "Screenshot", "󰹑")) + compare(state.entries[4].name, "Screenshot") + compare(state.entries[4].icon, "󰹑") + // Quotes inside the command become triple quotes so QProcess::splitCommand keeps them. + compare(state.entries[4].action, 'app-launch:sh -c "grim -g """$(slurp)""" - | wl-copy"') + } + function test_urlWithoutSchemeGetsHttps() { + state.select(5, -1) + state.beginParameter({name: "Open URL", kind: "url", icon: ""}) + verify(state.saveParameter("claude.ai/new", "claude.ai/new", "")) + compare(state.entries[5].action, 'app-launch:xdg-open "https://claude.ai/new"') + + state.select(6, -1) + state.beginParameter({name: "Open URL", kind: "url", icon: ""}) + verify(state.saveParameter("http://localhost:8080", "local", "")) + compare(state.entries[6].action, 'app-launch:xdg-open "http://localhost:8080"') + } + function test_dropAndSwap() { + state.drop({entry: {name: "Paste", action: "keystroke:Ctrl+V"}}, 1, -1) + state.drop({slot: 1, child: -1}, 3, -1) + compare(state.entries[3].name, "Paste") + compare(state.entries[1].action, "default") + } + function test_submenuAndChildLimit() { + state.select(0, -1) + state.assign({name: "Menu", children: []}) + state.drop({entry: {name: "Copy", action: "keystroke:Ctrl+C"}}, 0, 0) + compare(state.entries[0].children.length, 1) + compare(state.entries[0].children[0].name, "Copy") + for (let i = 1; i < 9; ++i) + state.drop({entry: {name: "Item", action: "media:Play"}}, 0, i) + compare(state.entries[0].children.length, 8) + verify(!state.drop({entry: {name: "Nested", children: []}}, 0, 0)) + } + function test_profileHistoryIsIsolated() { + state.assign({name: "A", action: "keystroke:A"}) + let a = state.entries + state.loadProfile("app", Array.from({length: 8}, () => ({action: "default"})), 80) + verify(!state.canUndo) + state.assign({name: "B", action: "keystroke:B"}) + state.loadProfile("global", a, 100) + state.undo() + compare(state.entries[0].action, "default") + } + function test_parameterCancelAndSave() { + state.beginParameter({name: "Shortcut", kind: "keystroke", icon: "K"}) + state.cancelParameter() + verify(!state.canUndo) + state.beginParameter({name: "Shortcut", kind: "keystroke", icon: "K"}) + state.saveParameter("Ctrl+Alt+T", "Terminal", "T") + compare(state.entries[0].action, "keystroke:Ctrl+Alt+T") + compare(state.entries[0].name, "Terminal") + } + function test_sizeUndoAndRedoInvalidation() { + state.changeSize(150) + state.undo() + compare(state.size, 100) + state.assign({name: "New", action: "keystroke:N"}) + verify(!state.canRedo) + } + function test_openPathQuotedForQProcess() { + state.beginParameter({name: "File", kind: "file", icon: "F"}) + state.saveParameter('/home/me/a "quote".txt', "File", "F") + compare(state.entries[0].action, 'app-launch:xdg-open "/home/me/a """quote""".txt"') + } +} diff --git a/tests/qml/tst_ring_file_browser.qml b/tests/qml/tst_ring_file_browser.qml new file mode 100644 index 00000000..411db45b --- /dev/null +++ b/tests/qml/tst_ring_file_browser.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "RingFileBrowser" + when: windowShown + + Component { id: factory; RingFileBrowser {} } + + // The device folder ships PNG renders, so it always has images to list. + readonly property string imageFolder: Qt.resolvedUrl("../../devices/mx-master-4").toString().replace(/^file:\/\//, "") + + function test_listsImagesAndChoosesOne() { + let browser = createTemporaryObject(factory, this) + browser.folderPath = imageFolder + browser.open() + tryVerify(() => browser.opened) + + // The grid must get real space and list the folder's images; a squeezed layout once left it + // zero pixels wide. + let grid = findChild(browser.contentItem, "fileGrid") + verify(grid !== null) + tryVerify(() => grid.count > 0) + verify(grid.width > 300) + + let chosenPath = "" + browser.chosen.connect(path => chosenPath = path) + browser.selectedPath = imageFolder + "/back.png" + browser.confirm() + compare(chosenPath, imageFolder + "/back.png") + verify(!browser.opened) + } + + function test_folderModeChoosesTheShownFolder() { + let browser = createTemporaryObject(factory, this) + browser.mode = "folder" + browser.folderPath = imageFolder + browser.open() + tryVerify(() => browser.opened) + + let chosenPath = "" + browser.chosen.connect(path => chosenPath = path) + browser.confirm() + compare(chosenPath, imageFolder) + } + + function test_goUpStopsAtRoot() { + let browser = createTemporaryObject(factory, this) + browser.folderPath = "/usr" + browser.goUp() + compare(browser.folderPath, "/") + browser.goUp() + compare(browser.folderPath, "/") + } +} diff --git a/tests/qml/tst_ring_library.qml b/tests/qml/tst_ring_library.qml new file mode 100644 index 00000000..79c19602 --- /dev/null +++ b/tests/qml/tst_ring_library.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "RingLibrary" + when: windowShown + width: 400; height: 700 + Component { id: factory; RingActionLibrary {} } + function test_searchAcrossGroups() { + let library = createTemporaryObject(factory, this, {width: 360, height: 650}) + verify(library !== null) + library.query = "copy" + verify(library.filtered.length > 0) + compare(library.filtered[0].name, "Copy") + library.query = "volume" + verify(library.filtered.length >= 2) + library.query = "no such action" + compare(library.filtered.length, 0) + } + function test_parameterAndSubmenuEntries() { + let library = createTemporaryObject(factory, this) + verify(library.actions.some(a => a.kind === "app")) + verify(library.actions.some(a => a.kind === "url")) + verify(library.actions.some(a => a.children !== undefined)) + verify(!library.actions.some(a => a.action === "dpi-cycle:")) + } +} diff --git a/tests/qml/tst_ring_preview.qml b/tests/qml/tst_ring_preview.qml new file mode 100644 index 00000000..902503c9 --- /dev/null +++ b/tests/qml/tst_ring_preview.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "RingPreview" + when: windowShown + visible: true + width: 1000; height: 900 + RingEditorState { id: state } + RingPreview { id: preview; anchors.fill: parent; editor: state } + function init() { + state.loadProfile("test", Array.from({length: 8}, (_, i) => ({name: "Slot " + i, icon: "C", action: "keystroke:C"})), 100) + } + function test_clickSelectsBubble() { + let bubble = findChild(preview, "bubble-2--1") + verify(bubble !== null) + mouseClick(bubble, bubble.width / 2, bubble.height / 2) + compare(state.selectedSlot, 2) + } + function test_realGeometryAndFan() { + let top = findChild(preview, "bubble-0--1") + compare(Math.round(top.y + top.height / 2), Math.round(preview.height / 2 - 82)) + state.assign({name: "Menu", icon: "M", children: []}) + tryVerify(() => findChild(preview, "bubble-0-0") !== null) + let child = findChild(preview, "bubble-0-0") + compare(Math.round(child.y + child.height / 2), Math.round(preview.height / 2 - 182)) + } + function test_bubbleDragSwaps() { + state.select(0, -1); state.rename("Moved") + let from = findChild(preview, "bubble-0--1") + let to = findChild(preview, "bubble-2--1") + let point = from.mapFromItem(to, to.width / 2, to.height / 2) + mouseDrag(from, from.width / 2, from.height / 2, point.x - from.width / 2, point.y - from.height / 2, Qt.LeftButton) + tryCompare(state.entries[2], "name", "Moved") + } +} diff --git a/tests/test_action_model.cpp b/tests/test_action_model.cpp index 31ec867e..abe8280b 100644 --- a/tests/test_action_model.cpp +++ b/tests/test_action_model.cpp @@ -278,3 +278,15 @@ TEST(ActionModel, buttonActionToNamePresetLooksUpLabel) { EXPECT_EQ(m.buttonActionToName(ButtonAction{ButtonAction::PresetRef, "show-desktop"}), "Show desktop"); } + +// QML reads rows through get(); it can't see the C++ role enum, and raw role numbers broke silently. +TEST_F(ActionModelTest, GetReturnsNamedFields) { + const QModelIndex idx = model.index(0); + const QVariantMap row = model.get(0); + EXPECT_EQ(row.value("name"), model.data(idx, ActionModel::NameRole)); + EXPECT_EQ(row.value("type"), model.data(idx, ActionModel::ActionTypeRole)); + EXPECT_EQ(row.value("payload"), model.data(idx, ActionModel::PayloadRole)); + EXPECT_EQ(row.value("category"), model.data(idx, ActionModel::CategoryRole)); + EXPECT_TRUE(model.get(-1).isEmpty()); + EXPECT_TRUE(model.get(model.rowCount()).isEmpty()); +} diff --git a/tests/test_actions_ring.cpp b/tests/test_actions_ring.cpp index 8e637ceb..87cf4adb 100644 --- a/tests/test_actions_ring.cpp +++ b/tests/test_actions_ring.cpp @@ -63,6 +63,25 @@ TEST(ActionsRing, ProfileRoundTrip) { EXPECT_EQ(loaded.ringSize, 150); } +TEST(ActionsRing, AdjusterSettingsSurviveRoundTrip) { + // An adjuster (e.g. volume) carries its commands in an "adjust" object; storage must keep it, + // both on a main slot and on a submenu child. + const QJsonObject volume{{"up", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"}, + {"down", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"}, + {"read", "wpctl get-volume @DEFAULT_AUDIO_SINK@"}, + {"press", "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"}}; + RingSlot adjuster{"V", "Volume", ButtonAction::parse("default"), {}, volume}; + RingSlot menu{"M", "Media", ButtonAction::parse("default"), {adjuster}, {}}; + + const auto loaded = ringSlotsFromJson(ringSlotsToJson({adjuster, menu})); + + ASSERT_EQ(loaded.size(), 2); + EXPECT_EQ(loaded[0].adjust, volume); + ASSERT_EQ(loaded[1].children.size(), 1); + EXPECT_EQ(loaded[1].children[0].adjust, volume); + EXPECT_TRUE(loaded[1].adjust.isEmpty()); +} + TEST(ActionsRing, LimitsAndNoNestedSubmenus) { RingSlot slot; slot.children.resize(12); diff --git a/tests/test_installed_apps_model.cpp b/tests/test_installed_apps_model.cpp new file mode 100644 index 00000000..c04720e4 --- /dev/null +++ b/tests/test_installed_apps_model.cpp @@ -0,0 +1,68 @@ +#include +#include +#include +#include +#include +#include "models/InstalledAppsModel.h" + +using namespace logitune; + +TEST(InstalledAppsModel, ReadsNamesIconsAndUsesDesktopLauncher) +{ + QTemporaryDir dir; + QFile file(dir.filePath("example.desktop")); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write("[Desktop Entry]\nType=Application\nName=Example\nIcon=example-icon\nExec=example %U\n"); + file.close(); + InstalledAppsModel model; + model.reload({dir.path()}); + ASSERT_EQ(model.rowCount(), 1); + auto app = model.get(0); + EXPECT_EQ(app["name"].toString(), "Example"); + EXPECT_EQ(app["icon"].toString(), "example-icon"); + EXPECT_EQ(app["command"].toString(), "gio launch \"" + file.fileName() + "\""); +} + +TEST(InstalledAppsModel, UserOverridesHideSystemEntriesAndFiltersNonApplications) +{ + QTemporaryDir user, system; + auto write = [](const QString &path, const QByteArray &body) { + QFile f(path); EXPECT_TRUE(f.open(QIODevice::WriteOnly)); f.write(body); + }; + write(system.filePath("same.desktop"), "[Desktop Entry]\nType=Application\nName=System\nExec=sys\n"); + write(user.filePath("same.desktop"), "[Desktop Entry]\nHidden=true\n"); + write(user.filePath("hidden.desktop"), "[Desktop Entry]\nType=Application\nName=Hidden\nNoDisplay=true\nExec=x\n"); + write(user.filePath("link.desktop"), "[Desktop Entry]\nType=Link\nName=Link\nURL=https://example.invalid\n"); + InstalledAppsModel model; + model.reload({user.path(), system.path()}); + EXPECT_EQ(model.rowCount(), 0); +} + +TEST(InstalledAppsModel, LocalizedNamesAndRecursiveDesktopIds) +{ + QTemporaryDir dir; + QDir().mkpath(dir.filePath("nested")); + QFile file(dir.filePath("nested/app.desktop")); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write("[Desktop Entry]\nType=Application\nName=Default\nName[de]=Anwendung\nExec=app\n"); + file.close(); + InstalledAppsModel model; + model.reload({dir.path()}, "de_DE"); + ASSERT_EQ(model.rowCount(), 1); + EXPECT_EQ(model.get(0)["name"].toString(), "Anwendung"); + EXPECT_EQ(model.get(0)["desktopId"].toString(), "nested-app.desktop"); +} + +TEST(InstalledAppsModel, LaunchCommandPreservesQuotedDesktopPathWithoutShell) +{ + QTemporaryDir dir; + QFile file(dir.filePath("quoted \"app\\name.desktop")); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write("[Desktop Entry]\nType=Application\nName=Quoted\nExec=app %U\n"); + file.close(); + InstalledAppsModel model; + model.reload({dir.path()}); + ASSERT_EQ(model.rowCount(), 1); + EXPECT_EQ(QProcess::splitCommand(model.get(0)["command"].toString()), + QStringList({"gio", "launch", file.fileName()})); +} diff --git a/tests/test_profile_model.cpp b/tests/test_profile_model.cpp index 8da412ae..4cfa1e54 100644 --- a/tests/test_profile_model.cpp +++ b/tests/test_profile_model.cpp @@ -191,3 +191,10 @@ TEST_F(ProfileModelTest, RemoveShiftsDisplayIndex) { EXPECT_EQ(model.rowCount(), 3); EXPECT_EQ(model.displayIndex(), 2); } + +TEST_F(ProfileModelTest, WmClassAtReadsTheProfileKey) { + model.addProfile(QStringLiteral("firefox"), QStringLiteral("Firefox")); + EXPECT_EQ(model.wmClassAt(1), QStringLiteral("firefox")); + EXPECT_EQ(model.wmClassAt(1), model.data(model.index(1), ProfileModel::WmClassRole).toString()); + EXPECT_TRUE(model.wmClassAt(99).isEmpty()); +} From 172297c636e96fe6a5055462d8578b74743a5191 Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Wed, 4 Nov 2026 01:06:06 +0000 Subject: [PATCH 5/7] fix(actions-ring): load the ring when the QML module is only in the binary Ring.qml and RingPreview.qml imported RingGeometry.js by relative path. That clashed with "import Logitune" in an installed build, where the module exists only inside the binary (qrc), and the ring overlay failed to load; local builds worked because Qt also found the module folder on disk next to the binary. The three geometry helpers now live in the two files that use them. --- src/app/CMakeLists.txt | 1 - src/app/qml/components/actionsring/Ring.qml | 9 ++++---- .../components/actionsring/RingGeometry.js | 8 ------- .../components/actionsring/RingPreview.qml | 23 +++++++++++++++---- 4 files changed, 23 insertions(+), 18 deletions(-) delete mode 100644 src/app/qml/components/actionsring/RingGeometry.js diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 6a93baf7..b2fb1ddd 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -88,7 +88,6 @@ qt_add_qml_module(logitune-app-lib qml/components/actionsring/RingActionLibrary.qml qml/components/actionsring/RingPreview.qml qml/components/actionsring/RingEditorBubble.qml - qml/components/actionsring/RingGeometry.js qml/components/actionsring/RingParameterCard.qml qml/components/actionsring/RingTextField.qml qml/components/actionsring/RingFlatButton.qml diff --git a/src/app/qml/components/actionsring/Ring.qml b/src/app/qml/components/actionsring/Ring.qml index 85696c53..ad8685e1 100644 --- a/src/app/qml/components/actionsring/Ring.qml +++ b/src/app/qml/components/actionsring/Ring.qml @@ -1,6 +1,5 @@ import QtQuick import Logitune -import "RingGeometry.js" as Geometry // Sizes and timings follow ~/re/logioptionsplus/findings/02-ring-behaviour.md. Styling is our own. Item { @@ -43,16 +42,18 @@ Item { function slotAngle(index) { // First slot at 12 o'clock, then clockwise in 45 degree steps. - return Geometry.rootAngle(index) + return index * 45 - 90 } function childAngle(parentIndex, childIndex, childCount) { // Children are 22.5 degrees apart, as a group centred on the parent's angle. - return Geometry.childAngle(parentIndex, childIndex, childCount) + let offset = (childIndex - (childCount - 1) / 2) * 22.5 + return slotAngle(parentIndex) + offset } function pointOnCircle(radius, angleDegrees) { - return Geometry.point(angleDegrees, radius) + let angle = angleDegrees * Math.PI / 180 + return Qt.point(radius * Math.cos(angle), radius * Math.sin(angle)) } function angleBetween(firstDegrees, secondDegrees) { diff --git a/src/app/qml/components/actionsring/RingGeometry.js b/src/app/qml/components/actionsring/RingGeometry.js deleted file mode 100644 index 87e46591..00000000 --- a/src/app/qml/components/actionsring/RingGeometry.js +++ /dev/null @@ -1,8 +0,0 @@ -.pragma library - -function rootAngle(index) { return index * 45 - 90 } -function childAngle(parent, index, count) { return rootAngle(parent) + (index - (count - 1) / 2) * 22.5 } -function point(angle, radius) { - let radians = angle * Math.PI / 180 - return Qt.point(Math.cos(radians) * radius, Math.sin(radians) * radius) -} diff --git a/src/app/qml/components/actionsring/RingPreview.qml b/src/app/qml/components/actionsring/RingPreview.qml index 84e6c825..a218c7e2 100644 --- a/src/app/qml/components/actionsring/RingPreview.qml +++ b/src/app/qml/components/actionsring/RingPreview.qml @@ -1,23 +1,36 @@ import QtQuick import QtQuick.Controls import Logitune -import "RingGeometry.js" as Geometry Item { id: root required property var editor + // The ring's geometry: first slot at 12 o'clock, clockwise in 45 degree steps; children 22.5 + // degrees apart, centred on their parent. Kept here rather than in a shared .js file: a relative + // JS import clashes with "import Logitune" once the module only exists inside the binary, which + // is how it's installed, and the ring then fails to load. + function rootAngle(index) { + return index * 45 - 90 + } + function childAngle(parentIndex, childIndex, count) { + return rootAngle(parentIndex) + (childIndex - (count - 1) / 2) * 22.5 + } + function point(angleDegrees, radius) { + let radians = angleDegrees * Math.PI / 180 + return Qt.point(Math.cos(radians) * radius, Math.sin(radians) * radius) + } readonly property real sizeScale: editor.size / 100 readonly property var targets: { let result = [] for (let i = 0; i < editor.entries.length; ++i) { - let p = Geometry.point(Geometry.rootAngle(i), 82 * sizeScale) + let p = root.point(root.rootAngle(i), 82 * sizeScale) result.push({slot: i, child: -1, entry: editor.entries[i], x: p.x, y: p.y}) } let parent = editor.entries[editor.selectedSlot] if (parent && parent.children !== undefined) { let count = Math.min(8, parent.children.length + 1) for (let j = 0; j < count; ++j) { - let p = Geometry.point(Geometry.childAngle(editor.selectedSlot, j, count), 182 * sizeScale) + let p = root.point(root.childAngle(editor.selectedSlot, j, count), 182 * sizeScale) result.push({slot: editor.selectedSlot, child: j, entry: parent.children[j] || {}, x: p.x, y: p.y}) } } @@ -31,8 +44,8 @@ Item { let result = [] for (let t of targets) { let isMainSlot = t.child < 0 - let fanAngle = Geometry.rootAngle(editor.selectedSlot) - let slotAngle = Geometry.rootAngle(t.slot) + let fanAngle = root.rootAngle(editor.selectedSlot) + let slotAngle = root.rootAngle(t.slot) let pointsIntoFan = isMainSlot && submenuOpen && angleBetween(slotAngle, fanAngle) <= 70 if (!pointsIntoFan) result.push(t) From f0bcc15a1105eef5d9ee1c67057c88a57fe3602c Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Wed, 4 Nov 2026 01:29:51 +0000 Subject: [PATCH 6/7] feat: haptic feedback and thumb-panel press force settings A Haptics page for mice that report HID++ 0x19B0 (haptics) or 0x19C0 (force-sensing button): turn feedback on or off, set its strength, and set how hard the MX Master 4's thumb panel must be pressed. Both are settings of the mouse, not of an application profile; they're saved per device in devices//panel.conf and reapplied on every connect. Sliders write when released. Also move the MX Master 4 haptic button's callout between Forward and Back, where it no longer overlaps Back, and add the QML modules the ring editor now needs (folderlistmodel, QtCore) to the Debian dependency lists. --- devices/mx-master-4/descriptor.json | 2 +- pkg/obs/debian.control | 2 +- scripts/package-deb.sh | 2 +- src/app/CMakeLists.txt | 1 + src/app/models/DeviceModel.cpp | 79 ++++++++ src/app/models/DeviceModel.h | 23 +++ src/app/qml/DeviceView.qml | 7 + src/app/qml/components/SideNav.qml | 3 + src/app/qml/pages/HapticsPage.qml | 171 ++++++++++++++++++ src/core/CMakeLists.txt | 2 + src/core/DeviceSession.cpp | 89 +++++++++ src/core/DeviceSession.h | 24 +++ src/core/PanelSettings.cpp | 53 ++++++ src/core/PanelSettings.h | 21 +++ src/core/PhysicalDevice.cpp | 2 + src/core/PhysicalDevice.h | 1 + src/core/hidpp/FeatureDispatcher.cpp | 1 + src/core/hidpp/HidppTypes.h | 1 + .../capabilities/ForceSensingCapability.cpp | 37 ++++ .../capabilities/ForceSensingCapability.h | 29 +++ .../hidpp/capabilities/HapticsCapability.cpp | 22 ++- .../hidpp/capabilities/HapticsCapability.h | 10 + tests/CMakeLists.txt | 2 + tests/qml/tst_HapticsPage.qml | 30 +++ tests/test_device_session.cpp | 16 ++ tests/test_panel_capabilities.cpp | 59 ++++++ tests/test_panel_settings.cpp | 34 ++++ 27 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 src/app/qml/pages/HapticsPage.qml create mode 100644 src/core/PanelSettings.cpp create mode 100644 src/core/PanelSettings.h create mode 100644 src/core/hidpp/capabilities/ForceSensingCapability.cpp create mode 100644 src/core/hidpp/capabilities/ForceSensingCapability.h create mode 100644 tests/qml/tst_HapticsPage.qml create mode 100644 tests/test_panel_capabilities.cpp create mode 100644 tests/test_panel_settings.cpp diff --git a/devices/mx-master-4/descriptor.json b/devices/mx-master-4/descriptor.json index 6d36cc53..546ab7ee 100644 --- a/devices/mx-master-4/descriptor.json +++ b/devices/mx-master-4/descriptor.json @@ -56,7 +56,7 @@ { "buttonIndex": 4, "xPct": 0.43, "yPct": 0.50, "side": "left", "labelOffsetYPct": 0.0 }, { "buttonIndex": 3, "xPct": 0.47, "yPct": 0.60, "side": "left", "labelOffsetYPct": 0.20 }, { "buttonIndex": 5, "xPct": 0.39, "yPct": 0.38, "side": "left", "labelOffsetYPct": -0.05 }, - { "buttonIndex": 8, "xPct": 0.36, "yPct": 0.73, "side": "left", "labelOffsetYPct": 0.20 } + { "buttonIndex": 8, "xPct": 0.36, "yPct": 0.73, "side": "left", "labelOffsetYPct": -0.08 } ], "scroll": [ { "buttonIndex": -1, "xPct": 0.73, "yPct": 0.23, "side": "right", "labelOffsetYPct": 0.0, "kind": "scrollwheel" }, diff --git a/pkg/obs/debian.control b/pkg/obs/debian.control index 19ba19d9..84088cea 100644 --- a/pkg/obs/debian.control +++ b/pkg/obs/debian.control @@ -8,7 +8,7 @@ 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, qml6-module-qt-labs-folderlistmodel, qml6-module-qtcore, 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/scripts/package-deb.sh b/scripts/package-deb.sh index ba774530..850edc40 100755 --- a/scripts/package-deb.sh +++ b/scripts/package-deb.sh @@ -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, libudev1, qml6-module-qt-labs-folderlistmodel, qml6-module-qtcore, 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/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index b2fb1ddd..14213f28 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -106,6 +106,7 @@ qt_add_qml_module(logitune-app-lib qml/pages/ButtonsPage.qml qml/pages/EasySwitchPage.qml qml/pages/SettingsPage.qml + qml/pages/HapticsPage.qml RESOURCES qml/assets/logitune-icon.svg qml/assets/logitune-tray.svg diff --git a/src/app/models/DeviceModel.cpp b/src/app/models/DeviceModel.cpp index 6f2a57ad..80906ada 100644 --- a/src/app/models/DeviceModel.cpp +++ b/src/app/models/DeviceModel.cpp @@ -18,6 +18,8 @@ namespace logitune { DeviceModel::DeviceModel(QObject *parent) : QAbstractListModel(parent) { + // Switching to another mouse changes every panel setting at once. + connect(this, &DeviceModel::selectedChanged, this, &DeviceModel::panelSettingsChanged); } // --------------------------------------------------------------------------- @@ -181,6 +183,10 @@ void DeviceModel::addPhysicalDevice(PhysicalDevice *device) emit smartShiftEnabledChanged(); emit smartShiftThresholdChanged(); }); + connect(device, &PhysicalDevice::panelSettingsChanged, this, [this, device]() { + if (device == selectedDevice()) + emit panelSettingsChanged(); + }); connect(device, &PhysicalDevice::scrollConfigChanged, this, [this, device]() { if (device != selectedDevice()) @@ -903,4 +909,77 @@ QString DeviceModel::deviceStatus() const return QStringLiteral("beta"); } +// Panel settings come straight from the selected mouse's active session; with no mouse +// connected they read as unsupported and the setters do nothing. +static DeviceSession *activeSessionOf(PhysicalDevice *device) +{ + return device ? device->primary() : nullptr; +} + +bool DeviceModel::hapticsSupported() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session && session->hapticsSupported(); +} + +bool DeviceModel::hapticsEnabled() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session && session->hapticsEnabled(); +} + +int DeviceModel::hapticsLevel() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session ? session->hapticsLevel() : 0; +} + +bool DeviceModel::hapticsFourLevelsOnly() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session && session->hapticsFourLevelsOnly(); +} + +bool DeviceModel::panelForceSupported() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session && session->panelForceSupported(); +} + +int DeviceModel::panelForce() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session ? session->panelForce() : 0; +} + +int DeviceModel::panelForceMin() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session ? session->panelForceMin() : 0; +} + +int DeviceModel::panelForceMax() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session ? session->panelForceMax() : 0; +} + +int DeviceModel::panelForceDefault() const +{ + auto *session = activeSessionOf(selectedDevice()); + return session ? session->panelForceDefault() : 0; +} + +void DeviceModel::setHaptics(bool enabled, int level) +{ + if (auto *session = activeSessionOf(selectedDevice())) + session->setHaptics(enabled, level); +} + +void DeviceModel::setPanelForce(int force) +{ + if (auto *session = activeSessionOf(selectedDevice())) + session->setPanelForce(force); +} + } // namespace logitune diff --git a/src/app/models/DeviceModel.h b/src/app/models/DeviceModel.h index 7872ab5f..40cb4a13 100644 --- a/src/app/models/DeviceModel.h +++ b/src/app/models/DeviceModel.h @@ -41,6 +41,17 @@ class DeviceModel : public QAbstractListModel { Q_PROPERTY(int dpiStep READ dpiStep NOTIFY selectedChanged) Q_PROPERTY(bool smartShiftEnabled READ smartShiftEnabled NOTIFY smartShiftEnabledChanged) Q_PROPERTY(int smartShiftThreshold READ smartShiftThreshold NOTIFY smartShiftThresholdChanged) + // Haptic feedback and the thumb panel's press force: settings of the mouse itself, not of an + // application profile. One signal covers them all; they're read and written together. + Q_PROPERTY(bool hapticsSupported READ hapticsSupported NOTIFY panelSettingsChanged) + Q_PROPERTY(bool hapticsEnabled READ hapticsEnabled NOTIFY panelSettingsChanged) + Q_PROPERTY(int hapticsLevel READ hapticsLevel NOTIFY panelSettingsChanged) + Q_PROPERTY(bool hapticsFourLevelsOnly READ hapticsFourLevelsOnly NOTIFY panelSettingsChanged) + Q_PROPERTY(bool panelForceSupported READ panelForceSupported NOTIFY panelSettingsChanged) + Q_PROPERTY(int panelForce READ panelForce NOTIFY panelSettingsChanged) + Q_PROPERTY(int panelForceMin READ panelForceMin NOTIFY panelSettingsChanged) + Q_PROPERTY(int panelForceMax READ panelForceMax NOTIFY panelSettingsChanged) + Q_PROPERTY(int panelForceDefault READ panelForceDefault NOTIFY panelSettingsChanged) Q_PROPERTY(bool scrollHiRes READ scrollHiRes NOTIFY scrollConfigChanged) Q_PROPERTY(bool scrollInvert READ scrollInvert NOTIFY scrollConfigChanged) Q_PROPERTY(QString activeProfileName READ activeProfileName NOTIFY activeProfileNameChanged) @@ -142,6 +153,17 @@ class DeviceModel : public QAbstractListModel { Q_INVOKABLE void setDPI(int value); Q_INVOKABLE void setSmartShift(bool enabled, int threshold); + Q_INVOKABLE void setHaptics(bool enabled, int level); + Q_INVOKABLE void setPanelForce(int force); + bool hapticsSupported() const; + bool hapticsEnabled() const; + int hapticsLevel() const; + bool hapticsFourLevelsOnly() const; + bool panelForceSupported() const; + int panelForce() const; + int panelForceMin() const; + int panelForceMax() const; + int panelForceDefault() const; Q_INVOKABLE void setScrollConfig(bool hiRes, bool invert); Q_INVOKABLE void setThumbWheelMode(const QString &mode); Q_INVOKABLE void resetAllProfiles(); @@ -190,6 +212,7 @@ public slots: void currentDPIChanged(); void smartShiftEnabledChanged(); void smartShiftThresholdChanged(); + void panelSettingsChanged(); void scrollConfigChanged(); void thumbWheelModeChanged(); void settingsReloaded(); diff --git a/src/app/qml/DeviceView.qml b/src/app/qml/DeviceView.qml index 14397b6c..db239a96 100644 --- a/src/app/qml/DeviceView.qml +++ b/src/app/qml/DeviceView.qml @@ -26,6 +26,8 @@ Item { contentStack.replace(easySwitchComponent, {}, StackView.Immediate) } else if (page === "settings") { contentStack.replace(settingsComponent, {}, StackView.Immediate) + } else if (page === "haptics") { + contentStack.replace(hapticsComponent, {}, StackView.Immediate) } else { contentStack.replace(placeholderComponent, {pageName: page}, @@ -140,6 +142,11 @@ Item { SettingsPage {} } + Component { + id: hapticsComponent + HapticsPage {} + } + // Placeholder page component Component { id: placeholderComponent diff --git a/src/app/qml/components/SideNav.qml b/src/app/qml/components/SideNav.qml index c460f4b9..8ec58106 100644 --- a/src/app/qml/components/SideNav.qml +++ b/src/app/qml/components/SideNav.qml @@ -25,6 +25,9 @@ Rectangle { ]; if (easySwitchSupported) items.push({ name: "easyswitch", label: "EASY-SWITCH", icon: "\u21C4", enabled: true }); + // Only for mice that report haptics or a force-sensing panel (MX Master 4). + if (DeviceModel.hapticsSupported || DeviceModel.panelForceSupported) + items.push({ name: "haptics", label: "HAPTICS", icon: "\u2248", enabled: true }); items.push({ name: "settings", label: "SETTINGS", icon: "\u2261", enabled: true }); return items; } diff --git a/src/app/qml/pages/HapticsPage.qml b/src/app/qml/pages/HapticsPage.qml new file mode 100644 index 00000000..35b767e0 --- /dev/null +++ b/src/app/qml/pages/HapticsPage.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Controls +import Logitune + +// Haptic feedback strength and the thumb panel's press force. Both are settings of the mouse itself, +// so they don't change with the per-application profile. Sliders write to the mouse only when +// released: a drag would otherwise send dozens of HID++ writes. +Item { + id: root + + readonly property int forceRange: Math.max(1, DeviceModel.panelForceMax - DeviceModel.panelForceMin) + + function forcePercent(force) { + return Math.round((force - DeviceModel.panelForceMin) * 100 / forceRange) + } + + Flickable { + anchors.fill: parent + contentHeight: content.implicitHeight + 64 + clip: true + + Column { + id: content + anchors { left: parent.left; top: parent.top; margins: 32 } + width: Math.min(460, root.width - 64) + spacing: 28 + + Text { + text: "Haptics" + font { pixelSize: 22; bold: true } + color: Theme.text + } + + Column { + objectName: "hapticFeedbackSection" + visible: DeviceModel.hapticsSupported + width: parent.width + spacing: 12 + + Text { + text: "Haptic feedback" + font { pixelSize: 15; bold: true } + color: Theme.text + } + Text { + width: parent.width + text: "The small buzzes you feel from the mouse, for example when you move across the Actions Ring." + wrapMode: Text.WordWrap + font.pixelSize: 12 + color: Theme.textSecondary + } + + LogituneToggle { + id: feedbackToggle + objectName: "hapticToggle" + label: "Feedback" + checked: DeviceModel.hapticsEnabled + onToggled: (checked) => DeviceModel.setHaptics(checked, DeviceModel.hapticsLevel) + } + + LogituneSlider { + id: strength + objectName: "hapticStrength" + width: parent.width + label: "Strength " + Math.round(value) + "%" + enabled: DeviceModel.hapticsEnabled + opacity: enabled ? 1 : 0.4 + from: DeviceModel.hapticsFourLevelsOnly ? 25 : 0 + to: 100 + stepSize: DeviceModel.hapticsFourLevelsOnly ? 25 : 1 + value: DeviceModel.hapticsLevel + onPressedChanged: { + if (!pressed) + DeviceModel.setHaptics(true, Math.round(value)) + } + } + Row { + width: parent.width + Text { text: "Subtle"; width: parent.width / 2; font.pixelSize: 11; color: Theme.textSecondary } + Text { + text: "Strong" + width: parent.width / 2 + horizontalAlignment: Text.AlignRight + font.pixelSize: 11 + color: Theme.textSecondary + } + } + } + + Column { + objectName: "panelForceSection" + visible: DeviceModel.panelForceSupported + width: parent.width + spacing: 12 + + Text { + text: "Thumb panel press force" + font { pixelSize: 15; bold: true } + color: Theme.text + } + Text { + width: parent.width + text: "How hard you have to press the thumb panel before it counts as a click." + wrapMode: Text.WordWrap + font.pixelSize: 12 + color: Theme.textSecondary + } + + LogituneSlider { + id: force + objectName: "panelForce" + width: parent.width + label: "Press force " + root.forcePercent(value) + "%" + from: DeviceModel.panelForceMin + to: DeviceModel.panelForceMax + stepSize: Math.max(1, Math.round(root.forceRange / 100)) + value: DeviceModel.panelForce + onPressedChanged: { + if (!pressed) + DeviceModel.setPanelForce(Math.round(value)) + } + } + Row { + width: parent.width + Text { text: "Light"; width: parent.width / 2; font.pixelSize: 11; color: Theme.textSecondary } + Text { + text: "Firm" + width: parent.width / 2 + horizontalAlignment: Text.AlignRight + font.pixelSize: 11 + color: Theme.textSecondary + } + } + + Rectangle { + id: resetForce + objectName: "panelForceReset" + readonly property bool atDefault: DeviceModel.panelForce === DeviceModel.panelForceDefault + width: resetText.implicitWidth + 24 + height: 32 + radius: 6 + color: resetHover.hovered && !atDefault ? Theme.hoverBg : "transparent" + border.color: Theme.border + opacity: atDefault ? 0.4 : 1 + + Text { + id: resetText + anchors.centerIn: parent + text: "Reset to default (" + root.forcePercent(DeviceModel.panelForceDefault) + "%)" + font.pixelSize: 12 + color: Theme.text + } + HoverHandler { id: resetHover } + TapHandler { + enabled: !resetForce.atDefault + onTapped: DeviceModel.setPanelForce(DeviceModel.panelForceDefault) + } + } + } + + Text { + visible: !DeviceModel.hapticsSupported && !DeviceModel.panelForceSupported + text: "This mouse hasn't reported any haptic settings. It may be asleep: move it and they'll appear." + width: parent.width + wrapMode: Text.WordWrap + font.pixelSize: 13 + color: Theme.textSecondary + } + } + } +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index e8f58d32..284d1822 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -22,7 +22,9 @@ target_sources(logitune-core PRIVATE hidpp/capabilities/ReprogControlsCapability.cpp ProfileEngine.cpp ActionsRing.cpp + PanelSettings.cpp hidpp/capabilities/HapticsCapability.cpp + hidpp/capabilities/ForceSensingCapability.cpp ActionExecutor.cpp DeviceRegistry.cpp devices/DescriptorWriter.cpp diff --git a/src/core/DeviceSession.cpp b/src/core/DeviceSession.cpp index 2ff70c39..3d360f16 100644 --- a/src/core/DeviceSession.cpp +++ b/src/core/DeviceSession.cpp @@ -1,4 +1,5 @@ #include "DeviceSession.h" +#include "PanelSettings.h" #include "DeviceRegistry.h" #include "interfaces/IDevice.h" #include "hidpp/features/Battery.h" @@ -341,6 +342,44 @@ void DeviceSession::enumerateAndSetup() } } + // Read haptic feedback strength and the thumb panel's press force. A device that lacks either + // feature, or doesn't answer, just leaves it unsupported; it must not hold up the connection. + m_hapticsRead = false; + if (m_hapticsDispatch) { + auto resp = m_features->call(m_transport.get(), m_deviceIndex, + m_hapticsDispatch->feature, m_hapticsDispatch->getConfigFn); + if (resp.has_value()) { + m_hapticConfig = m_hapticsDispatch->parseConfig(*resp); + m_hapticsRead = true; + qCDebug(lcDevice) << "haptics: enabled=" << m_hapticConfig.enabled + << "level=" << m_hapticConfig.level + << "fourLevelsOnly=" << m_hapticConfig.fourLevelsOnly; + } + } + m_panelForceRead = false; + m_forceSensingDispatch = hidpp::capabilities::resolveCapability( + m_features.get(), hidpp::capabilities::kForceSensingVariants); + if (m_forceSensingDispatch) { + // Button 0: the MX Master 4 has a single force-sensing button, its haptic thumb panel. + const std::array firstButton{0x00}; + auto infoResp = m_features->call(m_transport.get(), m_deviceIndex, + m_forceSensingDispatch->feature, + m_forceSensingDispatch->infoFn, firstButton); + auto currentResp = m_features->call(m_transport.get(), m_deviceIndex, + m_forceSensingDispatch->feature, + m_forceSensingDispatch->currentFn, firstButton); + if (infoResp.has_value() && currentResp.has_value()) { + m_panelForceInfo = m_forceSensingDispatch->parseInfo(*infoResp); + m_panelForce = m_forceSensingDispatch->parseCurrent(*currentResp); + m_panelForceRead = true; + qCDebug(lcDevice) << "panel force:" << m_panelForce + << "range" << m_panelForceInfo.minForce << "-" << m_panelForceInfo.maxForce + << "default" << m_panelForceInfo.defaultForce + << "changeable" << m_panelForceInfo.changeable; + } + } + emit panelSettingsChanged(); + // Read scroll wheel config if (m_features->hasFeature(hidpp::FeatureId::HiResWheel)) { auto modeResp = m_features->call(m_transport.get(), m_deviceIndex, @@ -484,6 +523,9 @@ void DeviceSession::enumerateAndSetup() } m_batteryPollTimer->start(); + // Needs m_connected and the command processor, so it runs here rather than where they're read. + applyStoredPanelSettings(); + emit setupComplete(); } @@ -528,6 +570,10 @@ void DeviceSession::disconnectCleanup() m_batteryDispatch.reset(); m_smartShiftDispatch.reset(); m_reprogControlsDispatch.reset(); + m_hapticsDispatch.reset(); + m_forceSensingDispatch.reset(); + m_hapticsRead = false; + m_panelForceRead = false; m_features.reset(); m_transport.reset(); m_device.reset(); @@ -837,6 +883,49 @@ void DeviceSession::playHaptic(uint8_t waveform) std::span(params)); } +void DeviceSession::setHaptics(bool enabled, int level) +{ + if (!m_connected || !m_commandProcessor || !m_hapticsDispatch || !m_hapticsRead) + return; + level = qBound(0, level, 100); + // Devices that only take four levels would otherwise round on their own, out of our sight. + if (m_hapticConfig.fourLevelsOnly) + level = qBound(25, ((level + 12) / 25) * 25, 100); + m_hapticConfig.enabled = enabled; + m_hapticConfig.level = level; + emit panelSettingsChanged(); + const auto params = m_hapticsDispatch->buildSetConfig(enabled, level); + m_commandProcessor->enqueue(m_hapticsDispatch->feature, m_hapticsDispatch->setConfigFn, + std::span(params)); + saveHaptics(panelSettingsPath(m_deviceSerial), enabled, level); +} + +void DeviceSession::setPanelForce(int force) +{ + if (!m_connected || !m_commandProcessor || !m_forceSensingDispatch || !panelForceSupported()) + return; + force = qBound(m_panelForceInfo.minForce, force, m_panelForceInfo.maxForce); + m_panelForce = force; + emit panelSettingsChanged(); + const auto params = m_forceSensingDispatch->buildSet(0, force); + m_commandProcessor->enqueue(m_forceSensingDispatch->feature, m_forceSensingDispatch->setFn, + std::span(params)); + savePanelForce(panelSettingsPath(m_deviceSerial), force); +} + +void DeviceSession::applyStoredPanelSettings() +{ + const auto stored = loadPanelSettings(panelSettingsPath(m_deviceSerial)); + if (hapticsSupported() && stored.hapticsLevel.has_value()) { + const bool enabled = stored.hapticsEnabled.value_or(true); + const bool differs = enabled != m_hapticConfig.enabled || *stored.hapticsLevel != m_hapticConfig.level; + if (differs) + setHaptics(enabled, *stored.hapticsLevel); + } + if (panelForceSupported() && stored.panelForce.has_value() && *stored.panelForce != m_panelForce) + setPanelForce(*stored.panelForce); +} + QString DeviceSession::thumbWheelMode() const { return m_thumbWheelMode; } bool DeviceSession::thumbWheelInvert() const { return m_thumbWheelInvert; } int DeviceSession::thumbWheelDefaultDirection() const { return m_thumbWheelDefaultDirection; } diff --git a/src/core/DeviceSession.h b/src/core/DeviceSession.h index 468b81b9..b4e73007 100644 --- a/src/core/DeviceSession.h +++ b/src/core/DeviceSession.h @@ -8,6 +8,7 @@ #include "hidpp/capabilities/SmartShiftCapability.h" #include "hidpp/capabilities/ReprogControlsCapability.h" #include "hidpp/capabilities/HapticsCapability.h" +#include "hidpp/capabilities/ForceSensingCapability.h" #include "hidpp/features/ReprogControls.h" #include #include @@ -55,6 +56,18 @@ class DeviceSession : public QObject { int dpiStep() const; bool smartShiftEnabled() const; int smartShiftThreshold() const; + + // Haptic feedback strength and the thumb panel's press force, read from the device on + // connect. "Supported" is false when the device lacks the feature or didn't answer. + bool hapticsSupported() const { return m_hapticsRead; } + bool hapticsEnabled() const { return m_hapticConfig.enabled; } + int hapticsLevel() const { return m_hapticConfig.level; } + bool hapticsFourLevelsOnly() const { return m_hapticConfig.fourLevelsOnly; } + bool panelForceSupported() const { return m_panelForceRead && m_panelForceInfo.changeable; } + int panelForce() const { return m_panelForce; } + int panelForceMin() const { return m_panelForceInfo.minForce; } + int panelForceMax() const { return m_panelForceInfo.maxForce; } + int panelForceDefault() const { return m_panelForceInfo.defaultForce; } bool scrollHiRes() const; bool scrollInvert() const; bool scrollRatchet() const; @@ -80,6 +93,10 @@ class DeviceSession : public QObject { void setScrollConfig(bool hiRes, bool invert); void divertButton(uint16_t controlId, bool divert, bool rawXY = false); void playHaptic(uint8_t waveform); + void setHaptics(bool enabled, int level); + void setPanelForce(int force); + // Reapplies the haptics and panel force saved for this device; called once connected. + void applyStoredPanelSettings(); void setThumbWheelMode(const QString &mode, bool invert = false); void flushCommandProcessor(); void touchResponseTime(); @@ -116,6 +133,7 @@ class DeviceSession : public QObject { void disconnected(); void batteryChanged(int level, bool charging); void smartShiftChanged(bool enabled, int threshold); + void panelSettingsChanged(); void currentDPIChanged(); void scrollConfigChanged(); void thumbWheelModeChanged(); @@ -144,6 +162,7 @@ class DeviceSession : public QObject { std::optional m_smartShiftDispatch; std::optional m_reprogControlsDispatch; std::optional m_hapticsDispatch; + std::optional m_forceSensingDispatch; hidpp::features::DivertedButtons m_divertedButtons; QTimer *m_reconnectTimer = nullptr; @@ -168,6 +187,11 @@ class DeviceSession : public QObject { int m_dpiStep = 50; bool m_smartShiftEnabled = false; int m_smartShiftThreshold = 0; + hidpp::capabilities::HapticConfig m_hapticConfig; + bool m_hapticsRead = false; + hidpp::capabilities::ForceButtonInfo m_panelForceInfo; + int m_panelForce = 0; + bool m_panelForceRead = false; bool m_scrollHiRes = false; bool m_scrollInvert = false; bool m_scrollRatchet = true; diff --git a/src/core/PanelSettings.cpp b/src/core/PanelSettings.cpp new file mode 100644 index 00000000..d8d39cdc --- /dev/null +++ b/src/core/PanelSettings.cpp @@ -0,0 +1,53 @@ +#include "PanelSettings.h" + +#include +#include +#include +#include + +namespace logitune { + +QString panelSettingsPath(const QString &serial) +{ + return QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + + "/devices/" + serial + "/panel.conf"; +} + +StoredPanelSettings loadPanelSettings(const QString &file) +{ + StoredPanelSettings stored; + if (!QFileInfo::exists(file)) + return stored; + + QSettings settings(file, QSettings::IniFormat); + if (settings.contains("Haptics/enabled")) + stored.hapticsEnabled = settings.value("Haptics/enabled").toBool(); + if (settings.contains("Haptics/level")) + stored.hapticsLevel = settings.value("Haptics/level").toInt(); + if (settings.contains("Panel/force")) + stored.panelForce = settings.value("Panel/force").toInt(); + return stored; +} + +// QSettings won't create missing parent folders, and a device seen for the first time has none. +static void ensureFolder(const QString &file) +{ + QDir().mkpath(QFileInfo(file).absolutePath()); +} + +void saveHaptics(const QString &file, bool enabled, int level) +{ + ensureFolder(file); + QSettings settings(file, QSettings::IniFormat); + settings.setValue("Haptics/enabled", enabled); + settings.setValue("Haptics/level", level); +} + +void savePanelForce(const QString &file, int force) +{ + ensureFolder(file); + QSettings settings(file, QSettings::IniFormat); + settings.setValue("Panel/force", force); +} + +} // namespace logitune diff --git a/src/core/PanelSettings.h b/src/core/PanelSettings.h new file mode 100644 index 00000000..e7527121 --- /dev/null +++ b/src/core/PanelSettings.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include + +namespace logitune { + +// Haptic feedback strength and thumb-panel press force, saved per device. These belong to the +// mouse, not to an application, so they live beside the device's profiles instead of in them, +// and are reapplied on every connect because the device doesn't reliably keep them. +struct StoredPanelSettings { + std::optional hapticsEnabled; + std::optional hapticsLevel; + std::optional panelForce; +}; + +QString panelSettingsPath(const QString &serial); +StoredPanelSettings loadPanelSettings(const QString &file); +void saveHaptics(const QString &file, bool enabled, int level); +void savePanelForce(const QString &file, int force); + +} // namespace logitune diff --git a/src/core/PhysicalDevice.cpp b/src/core/PhysicalDevice.cpp index cdd32c22..7a25f903 100644 --- a/src/core/PhysicalDevice.cpp +++ b/src/core/PhysicalDevice.cpp @@ -102,6 +102,8 @@ void PhysicalDevice::connectSessionSignals(DeviceSession *session) emit smartShiftChanged(enabled, threshold); emit stateChanged(); }); + connect(session, &DeviceSession::panelSettingsChanged, this, + &PhysicalDevice::panelSettingsChanged); connect(session, &DeviceSession::scrollConfigChanged, this, [this]() { emit scrollConfigChanged(); diff --git a/src/core/PhysicalDevice.h b/src/core/PhysicalDevice.h index d0c3fd80..9b9647b4 100644 --- a/src/core/PhysicalDevice.h +++ b/src/core/PhysicalDevice.h @@ -93,6 +93,7 @@ class PhysicalDevice : public QObject { // waiting on the catch-all stateChanged. DeviceModel subscribes to // these to drive targeted dataChanged roles. void smartShiftChanged(bool enabled, int threshold); + void panelSettingsChanged(); void scrollConfigChanged(); void thumbWheelModeChanged(); void currentDPIChanged(); diff --git a/src/core/hidpp/FeatureDispatcher.cpp b/src/core/hidpp/FeatureDispatcher.cpp index 8c8dfc95..9d7f1641 100644 --- a/src/core/hidpp/FeatureDispatcher.cpp +++ b/src/core/hidpp/FeatureDispatcher.cpp @@ -13,6 +13,7 @@ static constexpr FeatureId kKnownFeatures[] = { FeatureId::BatteryUnified, FeatureId::ChangeHost, FeatureId::Haptics, + FeatureId::ForceSensingButton, FeatureId::ReprogControls, FeatureId::ReprogControlsV2, FeatureId::ReprogControlsV2_2, diff --git a/src/core/hidpp/HidppTypes.h b/src/core/hidpp/HidppTypes.h index e1ba6581..0a95c559 100644 --- a/src/core/hidpp/HidppTypes.h +++ b/src/core/hidpp/HidppTypes.h @@ -28,6 +28,7 @@ enum class FeatureId : uint16_t { BatteryUnified = 0x1004, ChangeHost = 0x1814, Haptics = 0x19b0, + ForceSensingButton = 0x19c0, ReprogControls = 0x1b00, ReprogControlsV2 = 0x1b01, ReprogControlsV2_2= 0x1b02, diff --git a/src/core/hidpp/capabilities/ForceSensingCapability.cpp b/src/core/hidpp/capabilities/ForceSensingCapability.cpp new file mode 100644 index 00000000..9bea24e5 --- /dev/null +++ b/src/core/hidpp/capabilities/ForceSensingCapability.cpp @@ -0,0 +1,37 @@ +#include "ForceSensingCapability.h" + +namespace logitune::hidpp::capabilities { + +static int bigEndian16(const Report &reply, int offset) +{ + return (reply.params[offset] << 8) | reply.params[offset + 1]; +} + +// Info reply: big-endian u16s flags (bit 0 = changeable), default, max, min. +static ForceButtonInfo parseInfo(const Report &reply) +{ + ForceButtonInfo info; + info.changeable = bigEndian16(reply, 0) & 0x01; + info.defaultForce = bigEndian16(reply, 2); + info.maxForce = bigEndian16(reply, 4); + info.minForce = bigEndian16(reply, 6); + return info; +} + +static int parseCurrent(const Report &reply) +{ + return bigEndian16(reply, 0); +} + +static std::vector buildSet(uint8_t button, int force) +{ + const auto high = static_cast((force >> 8) & 0xFF); + const auto low = static_cast(force & 0xFF); + return {button, high, low}; +} + +const ForceSensingVariant kForceSensingVariants[1] = { + {FeatureId::ForceSensingButton, 0, 1, 2, 3, &parseInfo, &parseCurrent, &buildSet}, +}; + +} // namespace logitune::hidpp::capabilities diff --git a/src/core/hidpp/capabilities/ForceSensingCapability.h b/src/core/hidpp/capabilities/ForceSensingCapability.h new file mode 100644 index 00000000..ad42ca0c --- /dev/null +++ b/src/core/hidpp/capabilities/ForceSensingCapability.h @@ -0,0 +1,29 @@ +#pragma once +#include "hidpp/HidppTypes.h" + +namespace logitune::hidpp::capabilities { + +// Range a force-sensing button accepts, in the device's own units (MX Master 4 panel: 3842..6388). +struct ForceButtonInfo { + bool changeable = false; + int defaultForce = 0; + int maxForce = 0; + int minForce = 0; +}; + +// Feature 0x19C0: buttons that trigger at a configurable press force, like the MX Master 4's +// haptic thumb panel. Function numbers follow Solaar's ForceSensingButton. +struct ForceSensingVariant { + FeatureId feature; + uint8_t countFn; + uint8_t infoFn; + uint8_t currentFn; + uint8_t setFn; + ForceButtonInfo (*parseInfo)(const Report &reply); + int (*parseCurrent)(const Report &reply); + std::vector (*buildSet)(uint8_t button, int force); +}; + +extern const ForceSensingVariant kForceSensingVariants[1]; + +} // namespace logitune::hidpp::capabilities diff --git a/src/core/hidpp/capabilities/HapticsCapability.cpp b/src/core/hidpp/capabilities/HapticsCapability.cpp index 3fc3e5fe..83c563e2 100644 --- a/src/core/hidpp/capabilities/HapticsCapability.cpp +++ b/src/core/hidpp/capabilities/HapticsCapability.cpp @@ -1,9 +1,29 @@ #include "HapticsCapability.h" +#include + namespace logitune::hidpp::capabilities { static std::vector buildPlay(uint8_t waveform) { return {waveform}; } -const HapticsVariant kHapticsVariants[1] = {{FeatureId::Haptics, 4, &buildPlay}}; +// Reply: byte 0 bit 0 = feedback on, byte 1 = level (0..100), byte 2 bit 0 = only four levels. +static HapticConfig parseConfig(const Report &reply) +{ + HapticConfig config; + config.enabled = reply.params[0] & 0x01; + config.level = reply.params[1]; + config.fourLevelsOnly = reply.params[2] & 0x01; + return config; +} + +static std::vector buildSetConfig(bool enabled, int level) +{ + const auto clamped = static_cast(std::clamp(level, 0, 100)); + return {static_cast(enabled ? 0x01 : 0x00), clamped}; +} + +const HapticsVariant kHapticsVariants[1] = { + {FeatureId::Haptics, 4, &buildPlay, 1, 2, &parseConfig, &buildSetConfig}, +}; } // namespace logitune::hidpp::capabilities diff --git a/src/core/hidpp/capabilities/HapticsCapability.h b/src/core/hidpp/capabilities/HapticsCapability.h index e1d8948c..2fc297bf 100644 --- a/src/core/hidpp/capabilities/HapticsCapability.h +++ b/src/core/hidpp/capabilities/HapticsCapability.h @@ -3,10 +3,20 @@ namespace logitune::hidpp::capabilities { +struct HapticConfig { + bool enabled = false; + int level = 0; // 0..100 + bool fourLevelsOnly = false; // device only accepts 25/50/75/100 +}; + struct HapticsVariant { FeatureId feature; uint8_t playFn; std::vector (*buildPlay)(uint8_t waveform); + uint8_t getConfigFn; + uint8_t setConfigFn; + HapticConfig (*parseConfig)(const Report &reply); + std::vector (*buildSetConfig)(bool enabled, int level); }; extern const HapticsVariant kHapticsVariants[1]; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 90234e5c..5bc415a2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,8 @@ add_executable(logitune-tests test_transport.cpp test_feature_dispatcher.cpp test_capability_dispatch.cpp + test_panel_capabilities.cpp + test_panel_settings.cpp test_scroll_features.cpp test_features.cpp test_button_features.cpp diff --git a/tests/qml/tst_HapticsPage.qml b/tests/qml/tst_HapticsPage.qml new file mode 100644 index 00000000..7acfe601 --- /dev/null +++ b/tests/qml/tst_HapticsPage.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtTest +import Logitune + +TestCase { + name: "HapticsPage" + when: windowShown + width: 600 + height: 600 + + Component { id: pageFactory; HapticsPage { anchors.fill: parent } } + + // The test harness has no mouse connected, so nothing is reported: both sections must stay + // hidden rather than showing sliders that would do nothing. + function test_hidesControlsWithoutAMouse() { + let page = createTemporaryObject(pageFactory, this) + verify(page !== null) + compare(DeviceModel.hapticsSupported, false) + compare(DeviceModel.panelForceSupported, false) + compare(findChild(page, "hapticFeedbackSection").visible, false) + compare(findChild(page, "panelForceSection").visible, false) + } + + function test_forcePercentCoversTheRange() { + let page = createTemporaryObject(pageFactory, this) + // With no mouse the range is 0..0; forceRange guards against dividing by zero. + compare(page.forceRange, 1) + compare(page.forcePercent(0), 0) + } +} diff --git a/tests/test_device_session.cpp b/tests/test_device_session.cpp index 1ca2902f..2f87a8ba 100644 --- a/tests/test_device_session.cpp +++ b/tests/test_device_session.cpp @@ -208,3 +208,19 @@ TEST_F(DeviceSessionTest, AccessorsReturnNullables) { EXPECT_NE(session->transport(), nullptr); EXPECT_NE(session->device(), nullptr); } + +TEST_F(DeviceSessionTest, PanelSettingsUnsupportedBeforeConnect) { + auto session = makeSession(); + EXPECT_FALSE(session->hapticsSupported()); + EXPECT_FALSE(session->panelForceSupported()); +} + +TEST_F(DeviceSessionTest, PanelSettersSkipWhenNotConnected) { + auto session = makeSession(); + QSignalSpy spy(session.get(), &DeviceSession::panelSettingsChanged); + session->setHaptics(true, 60); + session->setPanelForce(5000); + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(session->hapticsLevel(), 0); + EXPECT_EQ(session->panelForce(), 0); +} diff --git a/tests/test_panel_capabilities.cpp b/tests/test_panel_capabilities.cpp new file mode 100644 index 00000000..4ea35da7 --- /dev/null +++ b/tests/test_panel_capabilities.cpp @@ -0,0 +1,59 @@ +#include +#include "hidpp/capabilities/HapticsCapability.h" +#include "hidpp/capabilities/ForceSensingCapability.h" + +using namespace logitune::hidpp; +using namespace logitune::hidpp::capabilities; + +static Report replyWith(std::initializer_list bytes) +{ + Report report; + size_t index = 0; + for (uint8_t byte : bytes) + report.params[index++] = byte; + return report; +} + +// Replies below are what the user's MX Master 4 sent when probed read-only. + +TEST(HapticsCapability, ParsesConfig) { + const auto &haptics = kHapticsVariants[0]; + const auto config = haptics.parseConfig(replyWith({0x01, 0x19, 0x00})); + EXPECT_TRUE(config.enabled); + EXPECT_EQ(config.level, 25); + EXPECT_FALSE(config.fourLevelsOnly); + + const auto off = haptics.parseConfig(replyWith({0x00, 0x32, 0x01})); + EXPECT_FALSE(off.enabled); + EXPECT_TRUE(off.fourLevelsOnly); +} + +TEST(HapticsCapability, BuildsSetConfig) { + const auto &haptics = kHapticsVariants[0]; + EXPECT_EQ(haptics.getConfigFn, 1); + EXPECT_EQ(haptics.setConfigFn, 2); + EXPECT_EQ(haptics.buildSetConfig(true, 60), (std::vector{0x01, 60})); + // Turning haptics off still sends a level; the device keeps it for when it's switched back on. + EXPECT_EQ(haptics.buildSetConfig(false, 60), (std::vector{0x00, 60})); + EXPECT_EQ(haptics.buildSetConfig(true, 140), (std::vector{0x01, 100})); +} + +TEST(ForceSensingCapability, ParsesButtonInfo) { + const auto &force = kForceSensingVariants[0]; + EXPECT_EQ(force.feature, FeatureId::ForceSensingButton); + const auto info = force.parseInfo(replyWith({0x00, 0x01, 0x12, 0xc3, 0x18, 0xf4, 0x0f, 0x02})); + EXPECT_TRUE(info.changeable); + EXPECT_EQ(info.defaultForce, 4803); + EXPECT_EQ(info.maxForce, 6388); + EXPECT_EQ(info.minForce, 3842); +} + +TEST(ForceSensingCapability, ParsesCurrentAndBuildsSet) { + const auto &force = kForceSensingVariants[0]; + EXPECT_EQ(force.parseCurrent(replyWith({0x0f, 0x02})), 3842); + EXPECT_EQ(force.buildSet(0, 4803), (std::vector{0x00, 0x12, 0xc3})); + EXPECT_EQ(force.countFn, 0); + EXPECT_EQ(force.infoFn, 1); + EXPECT_EQ(force.currentFn, 2); + EXPECT_EQ(force.setFn, 3); +} diff --git a/tests/test_panel_settings.cpp b/tests/test_panel_settings.cpp new file mode 100644 index 00000000..f5291047 --- /dev/null +++ b/tests/test_panel_settings.cpp @@ -0,0 +1,34 @@ +#include +#include +#include "PanelSettings.h" + +using namespace logitune; + +TEST(PanelSettings, MissingFileHasNothingStored) { + QTemporaryDir dir; + const auto stored = loadPanelSettings(dir.filePath("devices/ABC/panel.conf")); + EXPECT_FALSE(stored.hapticsEnabled.has_value()); + EXPECT_FALSE(stored.hapticsLevel.has_value()); + EXPECT_FALSE(stored.panelForce.has_value()); +} + +TEST(PanelSettings, SavesAndLoadsEachSettingSeparately) { + QTemporaryDir dir; + const QString file = dir.filePath("devices/ABC/panel.conf"); + + // Changing only the panel force must not invent a haptics value, and vice versa. + savePanelForce(file, 4803); + auto stored = loadPanelSettings(file); + EXPECT_EQ(stored.panelForce, 4803); + EXPECT_FALSE(stored.hapticsLevel.has_value()); + + saveHaptics(file, false, 60); + stored = loadPanelSettings(file); + EXPECT_EQ(stored.hapticsEnabled, false); + EXPECT_EQ(stored.hapticsLevel, 60); + EXPECT_EQ(stored.panelForce, 4803); +} + +TEST(PanelSettings, PathIsPerDevice) { + EXPECT_TRUE(panelSettingsPath("2616ZAR5CYK8").endsWith("/devices/2616ZAR5CYK8/panel.conf")); +} From 1b471e728b3609484a2091178ca39defa73f1888 Mon Sep 17 00:00:00 2001 From: Mykola Morgunov Date: Mon, 9 Nov 2026 22:04:56 +0000 Subject: [PATCH 7/7] fix: reapply the profile after a reconnect PhysicalDevice announced transportSetupComplete before stateChanged. On a reconnect the device model only reselects the device on stateChanged, so the profile was reapplied with nothing selected and skipped: button diversions (the Actions Ring trigger included), DPI and SmartShift were lost after every suspend or power cycle. Emit stateChanged first, and log when a reapply is skipped. --- src/app/services/ProfileOrchestrator.cpp | 7 ++++++- src/core/PhysicalDevice.cpp | 6 +++++- tests/test_physical_device.cpp | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/app/services/ProfileOrchestrator.cpp b/src/app/services/ProfileOrchestrator.cpp index c8152e69..73b23bc1 100644 --- a/src/app/services/ProfileOrchestrator.cpp +++ b/src/app/services/ProfileOrchestrator.cpp @@ -355,7 +355,12 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p) void ProfileOrchestrator::applyProfileToHardware(const Profile &p) { auto *session = m_selection ? m_selection->activeSession() : nullptr; - if (!session || !m_currentDevice) return; + if (!session || !m_currentDevice) { + qCWarning(lcApp) << "applyProfileToHardware skipped: session" << (session != nullptr) + << "currentDevice" << (m_currentDevice != nullptr) + << "activeSerial" << (m_selection ? m_selection->activeSerial() : QString()); + return; + } session->flushCommandProcessor(); diff --git a/src/core/PhysicalDevice.cpp b/src/core/PhysicalDevice.cpp index 7a25f903..e6e37842 100644 --- a/src/core/PhysicalDevice.cpp +++ b/src/core/PhysicalDevice.cpp @@ -88,8 +88,12 @@ void PhysicalDevice::connectSessionSignals(DeviceSession *session) { connect(session, &DeviceSession::setupComplete, this, [this]() { promoteBest(); - emit transportSetupComplete(); + // stateChanged first: on a reconnect the device model shows and selects the device on it, + // and the profile is reapplied to the selected device on transportSetupComplete. In the + // other order nothing is selected yet and the profile (button diversions included) is + // silently skipped after every suspend or power cycle. emit stateChanged(); + emit transportSetupComplete(); }); connect(session, &DeviceSession::disconnected, this, [this]() { promoteBest(); diff --git a/tests/test_physical_device.cpp b/tests/test_physical_device.cpp index bcfe90bd..f388250a 100644 --- a/tests/test_physical_device.cpp +++ b/tests/test_physical_device.cpp @@ -177,3 +177,21 @@ TEST_F(PhysicalDeviceTest, DisconnectedDelegatorsReturnDefaults) { EXPECT_FALSE(pd.batteryCharging()); EXPECT_EQ(pd.descriptor(), nullptr); } + +// After a reconnect the device model shows and selects the device on stateChanged, and the profile +// is reapplied to the selected device on transportSetupComplete. If transportSetupComplete came +// first, nothing was selected yet and the profile (button diversions, DPI, SmartShift) was skipped: +// the Actions Ring stopped working after every suspend or power cycle. +TEST_F(PhysicalDeviceTest, StateChangesBeforeTransportReadyOnSetup) { + PhysicalDevice pd(QStringLiteral("serial-1")); + auto s = makeSession("Bolt", true, "Test"); + pd.attachTransport(s.get()); + + QStringList order; + QObject::connect(&pd, &PhysicalDevice::stateChanged, [&order]() { order << "state"; }); + QObject::connect(&pd, &PhysicalDevice::transportSetupComplete, [&order]() { order << "ready"; }); + emit s->setupComplete(); + + ASSERT_EQ(order.size(), 2); + EXPECT_EQ(order, QStringList({"state", "ready"})); +}