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..546ab7ee 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.08 } ], "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/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/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 397dba0f..14213f28 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -8,13 +8,17 @@ 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 services/ProfileOrchestrator.cpp + services/ActionsRingService.cpp dialogs/GitHubIssueBuilder.cpp dialogs/CrashReportDialog.cpp TrayManager.cpp + SingleInstance.cpp + MainWindowController.cpp ) target_include_directories(logitune-app-lib PUBLIC @@ -23,7 +27,12 @@ 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) +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}" @@ -70,6 +79,20 @@ 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/actionsring/RingEditorState.qml + qml/components/actionsring/RingActionLibrary.qml + qml/components/actionsring/RingPreview.qml + qml/components/actionsring/RingEditorBubble.qml + 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 @@ -83,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/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 new file mode 100644 index 00000000..4f783f17 --- /dev/null +++ b/src/app/SingleInstance.cpp @@ -0,0 +1,71 @@ +#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()) { + const auto command = socket->readLine(); + if (command == "show\n") + 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(); + } + }; + connect(socket, &QLocalSocket::readyRead, this, receive); + receive(); + } + }); +} + +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)) + 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)) { + 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)) + 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..4e452798 --- /dev/null +++ b/src/app/SingleInstance.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace logitune { + +class SingleInstance : public QObject +{ + Q_OBJECT +public: + 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. + std::optional start(Command command = Command::Show); + +signals: + void showRequested(); + void quitRequested(); + void ringRequested(); + +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..6be522ac 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,5 @@ #include +#include "models/InstalledAppsModel.h" #include #include #include @@ -7,12 +8,10 @@ #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -25,6 +24,8 @@ #include "logging/CrashHandler.h" #include "dialogs/CrashReportDialog.h" #include "TrayManager.h" +#include "SingleInstance.h" +#include "MainWindowController.h" int main(int argc, char *argv[]) { @@ -57,7 +58,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 @@ -82,20 +83,32 @@ 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); + 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); 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"); + QObject::connect(&singleInstance, &logitune::SingleInstance::quitRequested, + &app, &QCoreApplication::quit); + const auto command = parser.isSet(quitOption) ? logitune::SingleInstance::Command::Quit + : 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"; + return *exitCode; } // Initialize logging — enabled by default, user can disable in Settings @@ -176,6 +189,7 @@ int main(int argc, char *argv[]) // QML engine qCInfo(lcApp) << "Creating QML engine..."; QQmlApplicationEngine engine; + QObject::connect(&engine, &QQmlApplicationEngine::quit, &app, &QCoreApplication::quit); qCInfo(lcApp) << "QML engine created"; class IconProvider : public QQuickImageProvider { @@ -190,6 +204,14 @@ 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()); + logitune::InstalledAppsModel installedApps; + engine.rootContext()->setContextProperty("InstalledApps", &installedApps); + 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). @@ -214,6 +236,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()) { @@ -224,73 +250,36 @@ 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]() { - for (auto *obj : engine.rootObjects()) { - if (auto *window = qobject_cast(obj)) { - window->show(); - window->raise(); - window->requestActivate(); - } - } - }); + 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. - 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 - // 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 || parser.isSet(ringOption)) { + 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"; + 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..bce92091 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")) @@ -171,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/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/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/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/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/DeviceView.qml b/src/app/qml/DeviceView.qml index 06144066..db239a96 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 @@ -25,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}, @@ -120,7 +123,7 @@ Item { // ButtonsPage component (default) Component { id: buttonsPageComponent - ButtonsPage {} + ButtonsPage { onConfigureRingRequested: root.configureRingRequested() } } // Point & Scroll page component @@ -139,6 +142,11 @@ Item { SettingsPage {} } + Component { + id: hapticsComponent + HapticsPage {} + } + // Placeholder page component Component { id: placeholderComponent diff --git a/src/app/qml/Main.qml b/src/app/qml/Main.qml index c8f2f41e..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 @@ -74,11 +75,6 @@ ApplicationWindow { color: Theme.background flags: Qt.FramelessWindowHint | Qt.Window - onClosing: function(close) { - close.accepted = false - hide() - } - // ── Custom title bar ──────────────────────────────────────────────── Rectangle { id: titleBar @@ -267,8 +263,18 @@ ApplicationWindow { onSettingsClicked: mainStack.push(appSettingsComponent) } } - Component { id: deviceViewComponent; DeviceView {} } - Component { id: appSettingsComponent; AppSettingsView {} } + Component { + id: deviceViewComponent + DeviceView { onConfigureRingRequested: mainStack.push(ringEditorComponent) } + } + Component { + id: ringEditorComponent + RingEditor { onBackRequested: mainStack.pop() } + } + Component { + id: appSettingsComponent + AppSettingsView { onQuitRequested: Qt.quit() } + } // Permission error overlay Rectangle { 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..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" @@ -30,6 +31,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 +506,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 +518,54 @@ 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 + + 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 { id: keystrokeComponent 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/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/components/actionsring/Glyph.qml b/src/app/qml/components/actionsring/Glyph.qml new file mode 100644 index 00000000..32c07b03 --- /dev/null +++ b/src/app/qml/components/actionsring/Glyph.qml @@ -0,0 +1,71 @@ +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: !glyph.isGif && (!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 + } + + // 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: (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 new file mode 100644 index 00000000..ad8685e1 --- /dev/null +++ b/src/app/qml/components/actionsring/Ring.qml @@ -0,0 +1,626 @@ +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 + } + + // 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() + 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) + } + } + + // 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 + 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 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, + 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 } } + + // Keyed on the name, not the full label, so a changing level doesn't restart the delay. + onNameChanged: { + shown = false + if (name === "") + return + if (forAdjuster) + shown = true + else + 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 + 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 + 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/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/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..0467a72c --- /dev/null +++ b/src/app/qml/components/actionsring/RingEditor.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +Item { + id: root + property alias editorState: state + readonly property var entries: state.entries + property alias selectedSlot: state.selectedSlot + signal backRequested() + + 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 { + anchors.fill: parent; anchors.margins: 20; spacing: 16 + RowLayout { + Layout.fillWidth: true + RingFlatButton { text: "← Back"; onClicked: root.backRequested() } + AppProfileBar { Layout.fillWidth: true; Layout.preferredHeight: 48; compact: true } + Item { Layout.preferredWidth: 80 } + } + 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 + 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 + } + 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) + } + } + } + } + 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/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..a218c7e2 --- /dev/null +++ b/src/app/qml/components/actionsring/RingPreview.qml @@ -0,0 +1,136 @@ +import QtQuick +import QtQuick.Controls +import Logitune + +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 = 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 = 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}) + } + } + 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 = root.rootAngle(editor.selectedSlot) + let slotAngle = root.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 new file mode 100644 index 00000000..81779561 --- /dev/null +++ b/src/app/qml/components/actionsring/RingSlotEditor.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logitune + +ColumnLayout { + id: root + required property var editor + spacing: 10 + + Label { text: "Selected action"; color: Theme.text; font.bold: true } + + RingTextField { + objectName: "slotName" + Layout.fillWidth: true + text: root.editor.selectedEntry.name || "" + placeholderText: "Action name" + onEditingFinished: root.editor.rename(text) + } + + RowLayout { + RingFlatButton { text: "Choose icon…"; onClicked: picker.open() } + RingFlatButton { text: "Clear"; onClicked: root.editor.clear() } + } + + 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/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/app/services/ActionsRingService.cpp b/src/app/services/ActionsRingService.cpp new file mode 100644 index 00000000..aac332fd --- /dev/null +++ b/src/app/services/ActionsRingService.cpp @@ -0,0 +1,237 @@ +#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 +#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::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); + 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..cf9816d5 --- /dev/null +++ b/src/app/services/ActionsRingService.h @@ -0,0 +1,67 @@ +#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); + // 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(); + QVariantList entries() const; + int size() const; + void setSize(int size); + Q_INVOKABLE void setEntries(const QVariantList &entries); +signals: + void editorChanged(); + void levelRead(const QString &output); +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..73b23bc1 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); @@ -332,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/ActionsRing.cpp b/src/core/ActionsRing.cpp new file mode 100644 index 00000000..88ca58c4 --- /dev/null +++ b/src/core/ActionsRing.cpp @@ -0,0 +1,58 @@ +#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)); + if (!slot.adjust.isEmpty()) + object.insert("adjust", slot.adjust); + 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()), {}, + object.value("adjust").toObject()}; + 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..15f56f5e --- /dev/null +++ b/src/core/ActionsRing.h @@ -0,0 +1,46 @@ +#pragma once +#include "ButtonAction.h" +#include +#include +#include + +namespace logitune { + +struct RingSlot { + QString icon; + 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(); +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..284d1822 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -21,6 +21,10 @@ target_sources(logitune-core PRIVATE hidpp/capabilities/SmartShiftCapability.cpp 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 @@ -33,6 +37,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..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" @@ -265,6 +266,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( @@ -338,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, @@ -481,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(); } @@ -525,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(); @@ -652,12 +701,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 +875,57 @@ 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)); +} + +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 1f5de69c..b4e73007 100644 --- a/src/core/DeviceSession.h +++ b/src/core/DeviceSession.h @@ -7,6 +7,9 @@ #include "hidpp/capabilities/BatteryCapability.h" #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 #include @@ -53,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; @@ -77,6 +92,11 @@ 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 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(); @@ -113,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(); @@ -140,6 +161,9 @@ class DeviceSession : public QObject { std::optional m_batteryDispatch; 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; QTimer *m_batteryPollTimer = nullptr; @@ -163,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..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(); @@ -102,6 +106,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/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..9d7f1641 100644 --- a/src/core/hidpp/FeatureDispatcher.cpp +++ b/src/core/hidpp/FeatureDispatcher.cpp @@ -12,6 +12,8 @@ static constexpr FeatureId kKnownFeatures[] = { FeatureId::BatteryStatus, 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 e4b92537..0a95c559 100644 --- a/src/core/hidpp/HidppTypes.h +++ b/src/core/hidpp/HidppTypes.h @@ -27,6 +27,8 @@ enum class FeatureId : uint16_t { BatteryStatus = 0x1000, 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 new file mode 100644 index 00000000..83c563e2 --- /dev/null +++ b/src/core/hidpp/capabilities/HapticsCapability.cpp @@ -0,0 +1,29 @@ +#include "HapticsCapability.h" + +#include + +namespace logitune::hidpp::capabilities { + +static std::vector buildPlay(uint8_t waveform) { return {waveform}; } + +// 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 new file mode 100644 index 00000000..2fc297bf --- /dev/null +++ b/src/core/hidpp/capabilities/HapticsCapability.h @@ -0,0 +1,24 @@ +#pragma once +#include "hidpp/HidppTypes.h" + +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]; + +} // 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 4168b173..5bc415a2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,9 +4,13 @@ include(GoogleTest) 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 + test_panel_capabilities.cpp + test_panel_settings.cpp test_scroll_features.cpp test_features.cpp test_button_features.cpp @@ -23,9 +27,11 @@ 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 + test_installed_apps_model.cpp test_device_model.cpp test_wmclass_resolution.cpp test_app_root.cpp @@ -59,7 +65,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_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/qml/tst_Main.qml b/tests/qml/tst_Main.qml new file mode 100644 index 00000000..9bb3609a --- /dev/null +++ b/tests/qml/tst_Main.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtTest +import Logitune + +TestCase { + name: "MainClosing" + when: windowShown + + Component { + id: mainComponent + Main {} + } + + function test_closeAlwaysAccepted() { + var main = createTemporaryObject(mainComponent, null) + verify(main !== null) + compare(main.close(), true) + compare(main.visible, false) + } +} diff --git a/tests/qml/tst_actions_ring.qml b/tests/qml/tst_actions_ring.qml new file mode 100644 index 00000000..d0973f85 --- /dev/null +++ b/tests/qml/tst_actions_ring.qml @@ -0,0 +1,93 @@ +import QtQuick +import QtTest +import "../../src/app/qml/components/actionsring" + +TestCase { + name: "ActionsRing" + when: windowShown + visible: true + 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: 1000, height: 700}) + 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") + } + 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 926ff3f8..27d47989 100644 --- a/tests/qml/tst_qml_main.cpp +++ b/tests/qml/tst_qml_main.cpp @@ -8,7 +8,24 @@ #include "models/ActionModel.h" #include "models/ProfileModel.h" #include "models/SettingsModel.h" +#include "models/InstalledAppsModel.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(); } + Q_INVOKABLE void setSize(int size) { m_size = size; 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 +50,8 @@ 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); + engine->rootContext()->setContextProperty("InstalledApps", &m_installedApps); m_deviceModel.setDisplayValues(1000, true, 50, true, false, "scroll"); } @@ -43,6 +62,8 @@ public slots: logitune::ActionModel m_actionModel; 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/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_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 new file mode 100644 index 00000000..87cf4adb --- /dev/null +++ b/tests/test_actions_ring.cpp @@ -0,0 +1,171 @@ +#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, 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); + 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_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_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_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_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")); +} 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"})); +} 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()); +} diff --git a/tests/test_single_instance.cpp b/tests/test_single_instance.cpp new file mode 100644 index 00000000..9ac7d6b2 --- /dev/null +++ b/tests/test_single_instance.cpp @@ -0,0 +1,93 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#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; + 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); +} + +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"))); +}