diff --git a/README.md b/README.md index 853156c..e6eb894 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ systems, architectures, or distributions may be limited. The optional `fedora-tools-settings` package adds a **Fedora Tools** page to Plasma System Settings. It discovers tools from this RPM repository, shows which are installed, and can install available tools after administrator -authorization. Individual tools do not require the settings module. +authorization. Installed tools can also be removed, and supported tools expose +their settings there. Individual tools do not require the settings module. ### Touchpad hold-tap @@ -44,6 +45,12 @@ second finger and emits a middle click. The held finger may move between taps. Repeating the tap while holding the first finger produces repeated clicks, including double middle clicks. +The minimum anchor age, required stationary time, tap durations, and emitted +mouse button or keyboard event can be changed through Fedora Tools settings or +with `touchpad-hold-tap-config`. A stationary time of zero allows recognition +while the anchor is moving. Custom plugin settings are stored under `/etc` and +survive package updates. Log out and back in after changing them. + The `touchpad-hold-tap` package requires libinput 1.30 or newer and a compositor that enables libinput plugins. Log out and back in after installation or an update so the compositor reloads the plugin. @@ -80,6 +87,9 @@ position and the application's windows form another. **Hide launcher when application starts** determines whether that separate launcher remains while the application is running. Other groups retain the Task Manager's sort order. +The first-window behavior and both Shift behaviors can be changed through +Fedora Tools settings. Changes take effect with the next shortcut press. + If application grouping is disabled—or an application is excluded from grouping—each of its windows remains a separate shortcut position. **Only group when the Task Manager is full** does not alter shortcut groups; that would make diff --git a/fedora-tools-settings/CMakeLists.txt b/fedora-tools-settings/CMakeLists.txt index 175ac0d..9d95288 100644 --- a/fedora-tools-settings/CMakeLists.txt +++ b/fedora-tools-settings/CMakeLists.txt @@ -16,7 +16,7 @@ include(KDECMakeSettings) include(KDECompilerSettings NO_POLICY_SCOPE) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Quick Test) -find_package(KF6 6.0 REQUIRED COMPONENTS Auth CoreAddons I18n KCMUtils) +find_package(KF6 6.0 REQUIRED COMPONENTS Auth Config CoreAddons I18n KCMUtils) add_subdirectory(src) if(BUILD_TESTING) diff --git a/fedora-tools-settings/README.md b/fedora-tools-settings/README.md index aeb2178..daefd68 100644 --- a/fedora-tools-settings/README.md +++ b/fedora-tools-settings/README.md @@ -14,6 +14,11 @@ installation to a narrowly scoped KAuth helper. The helper accepts only valid package names that independently resolve to that capability in the `fedora-tools` repository. +Installed tools can also be configured or removed from the module. Package +operations run one at a time; other actions remain disabled until DNF finishes. +The module currently configures task-group shortcut behavior, touchpad hold-tap +timing and output, and the experimental fingerprint workaround. + Installing this module does not install or activate any other tool. Tools remain usable without the module. diff --git a/fedora-tools-settings/fedora-tools-settings.spec b/fedora-tools-settings/fedora-tools-settings.spec index bc5a870..2ed58e7 100644 --- a/fedora-tools-settings/fedora-tools-settings.spec +++ b/fedora-tools-settings/fedora-tools-settings.spec @@ -1,6 +1,6 @@ Name: fedora-tools-settings Version: 0.1.0 -Release: 1%{?dist} +Release: 5%{?dist} Summary: Plasma System Settings module for Fedora Tools License: MIT @@ -14,6 +14,7 @@ BuildRequires: extra-cmake-modules BuildRequires: gcc-c++ BuildRequires: kf6-kauth-devel BuildRequires: kf6-kcmutils-devel +BuildRequires: kf6-kconfig-devel BuildRequires: kf6-ki18n-devel BuildRequires: kf6-kirigami-devel BuildRequires: qt6-qtdeclarative-devel @@ -58,5 +59,20 @@ install -Dpm 0644 %{SOURCE2} \ %{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy %changelog +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-5 +- Fix button contrast and timing-value loading +- Separate touchpad anchor age from its stationary-time setting + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-4 +- Improve tool descriptions and configuration help +- Add the minimum hold-tap duration setting + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-3 +- Add compact deterministic tool rows and per-tool configuration + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-2 +- Show installation progress beside the selected tool +- Add safe per-tool uninstall actions + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-1 - Initial package diff --git a/fedora-tools-settings/src/CMakeLists.txt b/fedora-tools-settings/src/CMakeLists.txt index 3dd3505..cd4d1d3 100644 --- a/fedora-tools-settings/src/CMakeLists.txt +++ b/fedora-tools-settings/src/CMakeLists.txt @@ -13,6 +13,7 @@ target_link_libraries(kcm_fedora_tools PRIVATE Qt6::Core Qt6::Quick KF6::AuthCore + KF6::ConfigCore KF6::CoreAddons KF6::I18n KF6::KCMUtilsQuick diff --git a/fedora-tools-settings/src/fedoratoolskcm.cpp b/fedora-tools-settings/src/fedoratoolskcm.cpp index d448ee4..49f1bb8 100644 --- a/fedora-tools-settings/src/fedoratoolskcm.cpp +++ b/fedora-tools-settings/src/fedoratoolskcm.cpp @@ -4,10 +4,14 @@ #include #include +#include #include #include #include +#include +#include +#include #include #include @@ -33,8 +37,10 @@ FedoraToolsKcm::FedoraToolsKcm(QObject *parent, const KPluginMetaData &data) } setMessage(i18n("Could not start %1.", m_query.program()), true); m_stage = QueryStage::Idle; + setPackageOperation({}, {}); setBusy(false); }); + loadToolSettings(); QTimer::singleShot(0, this, [this] { refresh(); }); @@ -65,6 +71,56 @@ bool FedoraToolsKcm::fingerprintEnabled() const return m_fingerprintEnabled; } +QString FedoraToolsKcm::activePackage() const +{ + return m_activePackage; +} + +QString FedoraToolsKcm::packageOperation() const +{ + return m_packageOperation; +} + +bool FedoraToolsKcm::shortcutStartWithFirst() const +{ + return m_shortcutStartWithFirst; +} + +bool FedoraToolsKcm::shortcutInitialShiftOpensNew() const +{ + return m_shortcutInitialShiftOpensNew; +} + +bool FedoraToolsKcm::shortcutShiftCyclesBackward() const +{ + return m_shortcutShiftCyclesBackward; +} + +int FedoraToolsKcm::touchpadMinimumAnchorAge() const +{ + return m_touchpadMinimumAnchorAge; +} + +int FedoraToolsKcm::touchpadMinimumPause() const +{ + return m_touchpadMinimumPause; +} + +int FedoraToolsKcm::touchpadMinimumTap() const +{ + return m_touchpadMinimumTap; +} + +int FedoraToolsKcm::touchpadMaximumTap() const +{ + return m_touchpadMaximumTap; +} + +QString FedoraToolsKcm::touchpadOutputEvent() const +{ + return m_touchpadOutputEvent; +} + void FedoraToolsKcm::refresh(bool refreshMetadata) { if (m_busy) { @@ -98,12 +154,107 @@ void FedoraToolsKcm::installTool(const QString &packageName) return; } + setPackageOperation(packageName, QStringLiteral("install")); runAuthorizedAction(QStringLiteral("installpackage"), {{QStringLiteral("packageName"), packageName}}, i18n("%1 is now installed.", packageName), true); } +void FedoraToolsKcm::removeTool(const QString &packageName) +{ + if (m_busy || !m_tools.mayRemove(packageName)) { + return; + } + + if (packageName == QLatin1String("plasma-fingerprint-workaround") && m_fingerprintEnabled) { + setMessage(i18n("Disable the fingerprint workaround before removing its controller."), true); + return; + } + + if (packageName == QLatin1String("plasma-task-group-shortcuts")) { + QProcess restore; + restore.start(QStringLiteral("/usr/bin/plasma-task-group-shortcuts"), {QStringLiteral("--restore-shortcuts")}); + if (!restore.waitForFinished(10 * 1000) || restore.exitStatus() != QProcess::NormalExit || restore.exitCode() != 0) { + restore.kill(); + restore.waitForFinished(); + setMessage(i18n("Could not restore the original Meta+number shortcuts."), true); + return; + } + } + + setPackageOperation(packageName, QStringLiteral("remove")); + runAuthorizedAction(QStringLiteral("removepackage"), + {{QStringLiteral("packageName"), packageName}}, + i18n("%1 was removed.", packageName), + true); +} + +void FedoraToolsKcm::saveShortcutSettings(bool startWithFirst, + bool initialShiftOpensNew, + bool shiftCyclesBackward) +{ + const auto config = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc")); + KConfigGroup group(config, QStringLiteral("Settings")); + group.writeEntry("StartWithFirstWindow", startWithFirst); + group.writeEntry("InitialShiftOpensNewInstance", initialShiftOpensNew); + group.writeEntry("ShiftCyclesBackward", shiftCyclesBackward); + group.sync(); + loadToolSettings(); + setMessage(i18n("Shortcut settings were saved.")); +} + +void FedoraToolsKcm::resetShortcutSettings() +{ + const auto config = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc")); + KConfigGroup group(config, QStringLiteral("Settings")); + group.deleteGroup(); + group.sync(); + loadToolSettings(); + setMessage(i18n("Shortcut settings were reset to their defaults.")); +} + +void FedoraToolsKcm::saveTouchpadSettings(int minimumAnchorAge, + int minimumPause, + int minimumTap, + int maximumTap, + const QString &outputEvent) +{ + static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$")); + if (m_busy) { + return; + } + if (minimumAnchorAge < 0 || minimumAnchorAge > 2000 + || minimumPause < 0 || minimumPause > 2000 + || minimumTap < 0 || minimumTap > maximumTap + || maximumTap < 1 || maximumTap > 2000 + || !eventName.match(outputEvent).hasMatch()) { + setMessage(i18n("Enter valid timing values and an evdev BTN_* or KEY_* output event."), true); + return; + } + + runAuthorizedAction(QStringLiteral("configuretouchpad"), + {{QStringLiteral("operation"), QStringLiteral("save")}, + {QStringLiteral("minimumAnchorAge"), minimumAnchorAge}, + {QStringLiteral("minimumPause"), minimumPause}, + {QStringLiteral("minimumTap"), minimumTap}, + {QStringLiteral("maximumTap"), maximumTap}, + {QStringLiteral("outputEvent"), outputEvent}}, + i18n("Touchpad settings were saved. Log out and back in to apply them."), + false); +} + +void FedoraToolsKcm::resetTouchpadSettings() +{ + if (m_busy) { + return; + } + runAuthorizedAction(QStringLiteral("configuretouchpad"), + {{QStringLiteral("operation"), QStringLiteral("reset")}}, + i18n("Touchpad settings were reset. Log out and back in to apply the defaults."), + false); +} + void FedoraToolsKcm::enableFingerprintWorkaround(const QUrl &rpmFile, bool force) { if (m_busy) { @@ -155,6 +306,7 @@ void FedoraToolsKcm::queryFinished(int exitCode, QProcess::ExitStatus exitStatus if (exitStatus != QProcess::NormalExit) { setMessage(i18n("Package discovery terminated unexpectedly."), true); m_stage = QueryStage::Idle; + setPackageOperation({}, {}); setBusy(false); return; } @@ -183,6 +335,7 @@ void FedoraToolsKcm::queryFinished(int exitCode, QProcess::ExitStatus exitStatus setMessage(details.isEmpty() ? i18n("The Fedora Tools repository is unavailable.") : details, true); updateFingerprintStatus(); m_stage = QueryStage::Idle; + setPackageOperation({}, {}); setBusy(false); return; } @@ -244,6 +397,7 @@ void FedoraToolsKcm::finishRefresh() m_tools.setPackages(m_installedPackages, m_availablePackages, m_updates); updateFingerprintStatus(); m_stage = QueryStage::Idle; + setPackageOperation({}, {}); setBusy(false); } @@ -274,6 +428,7 @@ void FedoraToolsKcm::actionFinished(KJob *job, const QString &successMessage, bo const QString helperMessage = executeJob ? executeJob->data().value(QStringLiteral("message")).toString() : QString(); const QString errorMessage = helperMessage.isEmpty() ? job->errorString() : helperMessage; setMessage(errorMessage, true); + setPackageOperation({}, {}); setBusy(false); return; } @@ -283,6 +438,7 @@ void FedoraToolsKcm::actionFinished(KJob *job, const QString &successMessage, bo refresh(); } else { updateFingerprintStatus(); + loadToolSettings(); setBusy(false); } setMessage(successMessage); @@ -297,6 +453,16 @@ void FedoraToolsKcm::setBusy(bool busy) Q_EMIT busyChanged(); } +void FedoraToolsKcm::setPackageOperation(const QString &packageName, const QString &operation) +{ + if (m_activePackage == packageName && m_packageOperation == operation) { + return; + } + m_activePackage = packageName; + m_packageOperation = operation; + Q_EMIT packageOperationChanged(); +} + void FedoraToolsKcm::setMessage(const QString &message, bool error) { if (m_message == message && m_error == error) { @@ -320,4 +486,41 @@ void FedoraToolsKcm::updateFingerprintStatus() } } +void FedoraToolsKcm::loadToolSettings() +{ + const auto shortcutConfig = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc")); + shortcutConfig->reparseConfiguration(); + const KConfigGroup shortcuts(shortcutConfig, QStringLiteral("Settings")); + m_shortcutStartWithFirst = shortcuts.readEntry("StartWithFirstWindow", true); + m_shortcutInitialShiftOpensNew = shortcuts.readEntry("InitialShiftOpensNewInstance", true); + m_shortcutShiftCyclesBackward = shortcuts.readEntry("ShiftCyclesBackward", true); + + m_touchpadMinimumAnchorAge = 100; + m_touchpadMinimumPause = 100; + m_touchpadMinimumTap = 10; + m_touchpadMaximumTap = 150; + m_touchpadOutputEvent = QStringLiteral("BTN_MIDDLE"); + QFile touchpadConfig(QStringLiteral("/etc/touchpad-hold-tap.conf")); + if (touchpadConfig.open(QIODevice::ReadOnly | QIODevice::Text)) { + while (!touchpadConfig.atEnd()) { + const QList setting = touchpadConfig.readLine().trimmed().split('='); + if (setting.size() != 2) { + continue; + } + if (setting.at(0) == "MINIMUM_ANCHOR_AGE_MS") { + m_touchpadMinimumAnchorAge = setting.at(1).toInt(); + } else if (setting.at(0) == "MINIMUM_PAUSE_MS") { + m_touchpadMinimumPause = setting.at(1).toInt(); + } else if (setting.at(0) == "MINIMUM_TAP_MS") { + m_touchpadMinimumTap = setting.at(1).toInt(); + } else if (setting.at(0) == "MAXIMUM_TAP_MS") { + m_touchpadMaximumTap = setting.at(1).toInt(); + } else if (setting.at(0) == "OUTPUT_EVENT") { + m_touchpadOutputEvent = QString::fromLatin1(setting.at(1)); + } + } + } + Q_EMIT toolSettingsChanged(); +} + #include "fedoratoolskcm.moc" diff --git a/fedora-tools-settings/src/fedoratoolskcm.h b/fedora-tools-settings/src/fedoratoolskcm.h index 4250f80..cdcb94b 100644 --- a/fedora-tools-settings/src/fedoratoolskcm.h +++ b/fedora-tools-settings/src/fedoratoolskcm.h @@ -21,6 +21,16 @@ class FedoraToolsKcm : public KQuickConfigModule Q_PROPERTY(QString message READ message NOTIFY messageChanged) Q_PROPERTY(bool error READ error NOTIFY messageChanged) Q_PROPERTY(bool fingerprintEnabled READ fingerprintEnabled NOTIFY fingerprintEnabledChanged) + Q_PROPERTY(QString activePackage READ activePackage NOTIFY packageOperationChanged) + Q_PROPERTY(QString packageOperation READ packageOperation NOTIFY packageOperationChanged) + Q_PROPERTY(bool shortcutStartWithFirst READ shortcutStartWithFirst NOTIFY toolSettingsChanged) + Q_PROPERTY(bool shortcutInitialShiftOpensNew READ shortcutInitialShiftOpensNew NOTIFY toolSettingsChanged) + Q_PROPERTY(bool shortcutShiftCyclesBackward READ shortcutShiftCyclesBackward NOTIFY toolSettingsChanged) + Q_PROPERTY(int touchpadMinimumAnchorAge READ touchpadMinimumAnchorAge NOTIFY toolSettingsChanged) + Q_PROPERTY(int touchpadMinimumPause READ touchpadMinimumPause NOTIFY toolSettingsChanged) + Q_PROPERTY(int touchpadMinimumTap READ touchpadMinimumTap NOTIFY toolSettingsChanged) + Q_PROPERTY(int touchpadMaximumTap READ touchpadMaximumTap NOTIFY toolSettingsChanged) + Q_PROPERTY(QString touchpadOutputEvent READ touchpadOutputEvent NOTIFY toolSettingsChanged) public: FedoraToolsKcm(QObject *parent, const KPluginMetaData &data); @@ -30,10 +40,25 @@ public: QString message() const; bool error() const; bool fingerprintEnabled() const; + QString activePackage() const; + QString packageOperation() const; + bool shortcutStartWithFirst() const; + bool shortcutInitialShiftOpensNew() const; + bool shortcutShiftCyclesBackward() const; + int touchpadMinimumAnchorAge() const; + int touchpadMinimumPause() const; + int touchpadMinimumTap() const; + int touchpadMaximumTap() const; + QString touchpadOutputEvent() const; Q_INVOKABLE void refresh(bool refreshMetadata = false); Q_INVOKABLE void clearMessage(); Q_INVOKABLE void installTool(const QString &packageName); + Q_INVOKABLE void removeTool(const QString &packageName); + Q_INVOKABLE void saveShortcutSettings(bool startWithFirst, bool initialShiftOpensNew, bool shiftCyclesBackward); + Q_INVOKABLE void resetShortcutSettings(); + Q_INVOKABLE void saveTouchpadSettings(int minimumAnchorAge, int minimumPause, int minimumTap, int maximumTap, const QString &outputEvent); + Q_INVOKABLE void resetTouchpadSettings(); Q_INVOKABLE void enableFingerprintWorkaround(const QUrl &rpmFile, bool force); Q_INVOKABLE void disableFingerprintWorkaround(); @@ -41,6 +66,8 @@ Q_SIGNALS: void busyChanged(); void messageChanged(); void fingerprintEnabledChanged(); + void packageOperationChanged(); + void toolSettingsChanged(); private: enum class QueryStage { @@ -57,8 +84,10 @@ private: void runAuthorizedAction(const QString &name, const QVariantMap &arguments, const QString &successMessage, bool refreshTools); void actionFinished(KJob *job, const QString &successMessage, bool refreshTools); void setBusy(bool busy); + void setPackageOperation(const QString &packageName, const QString &operation); void setMessage(const QString &message, bool error = false); void updateFingerprintStatus(); + void loadToolSettings(); ToolModel m_tools; QProcess m_query; @@ -70,5 +99,15 @@ private: bool m_busy = false; bool m_error = false; bool m_fingerprintEnabled = false; + QString m_activePackage; + QString m_packageOperation; QString m_message; + bool m_shortcutStartWithFirst = true; + bool m_shortcutInitialShiftOpensNew = true; + bool m_shortcutShiftCyclesBackward = true; + int m_touchpadMinimumAnchorAge = 100; + int m_touchpadMinimumPause = 100; + int m_touchpadMinimumTap = 10; + int m_touchpadMaximumTap = 150; + QString m_touchpadOutputEvent = QStringLiteral("BTN_MIDDLE"); }; diff --git a/fedora-tools-settings/src/helper.cpp b/fedora-tools-settings/src/helper.cpp index 7f86ae8..5575d3f 100644 --- a/fedora-tools-settings/src/helper.cpp +++ b/fedora-tools-settings/src/helper.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace { @@ -71,6 +72,37 @@ bool isPublishedTool(const QString &packageName) } return false; } + +bool isInstalledTool(const QString &packageName) +{ + QProcess process; + process.start(QStringLiteral("/usr/bin/rpm"), + {QStringLiteral("-q"), + QStringLiteral("--whatprovides"), + QStringLiteral("fedora-tools-tool"), + QStringLiteral("--qf"), + QStringLiteral("%{name}\n")}); + if (!process.waitForFinished(30 * 1000) || process.exitCode() != 0) { + return false; + } + + for (const QByteArray &line : process.readAllStandardOutput().split('\n')) { + if (QString::fromUtf8(line) == packageName) { + return true; + } + } + return false; +} + +bool fingerprintWorkaroundEnabled() +{ + QProcess process; + process.start(QStringLiteral("/usr/bin/plasma-fingerprint-workaround"), {QStringLiteral("status")}); + return process.waitForFinished(10 * 1000) + && process.exitStatus() == QProcess::NormalExit + && process.exitCode() == 0 + && process.readAllStandardOutput().startsWith("Fingerprint workaround: enabled"); +} } class FedoraToolsHelper : public QObject @@ -94,6 +126,76 @@ public Q_SLOTS: packageName}); } + KAuth::ActionReply removepackage(const QVariantMap &arguments) + { + const QString packageName = arguments.value(QStringLiteral("packageName")).toString(); + if (!isValidPackageName(packageName) || !isInstalledTool(packageName)) { + KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); + reply.addData(QStringLiteral("message"), QStringLiteral("The requested package is not an installed Fedora tool.")); + return reply; + } + + if (packageName == QLatin1String("plasma-fingerprint-workaround") && fingerprintWorkaroundEnabled()) { + KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); + reply.addData(QStringLiteral("message"), QStringLiteral("Disable the fingerprint workaround before removing its controller.")); + return reply; + } + + return commandReply(QStringLiteral("/usr/bin/dnf5"), + {QStringLiteral("remove"), + QStringLiteral("--assumeyes"), + QStringLiteral("--no-autoremove"), + packageName}); + } + + KAuth::ActionReply configuretouchpad(const QVariantMap &arguments) + { + if (!isInstalledTool(QStringLiteral("touchpad-hold-tap"))) { + KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); + reply.addData(QStringLiteral("message"), QStringLiteral("The touchpad hold-tap tool is not installed.")); + return reply; + } + + const QString operation = arguments.value(QStringLiteral("operation")).toString(); + const QString controller = QStringLiteral("/usr/bin/touchpad-hold-tap-config"); + if (!QFileInfo(controller).isExecutable()) { + KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); + reply.addData(QStringLiteral("message"), QStringLiteral("The touchpad configurator is not installed.")); + return reply; + } + + if (operation == QLatin1String("reset")) { + return commandReply(controller, {QStringLiteral("reset")}); + } + if (operation != QLatin1String("save")) { + return KAuth::ActionReply::InvalidActionReply(); + } + + const int minimumAnchorAge = arguments.value(QStringLiteral("minimumAnchorAge")).toInt(); + const int minimumPause = arguments.value(QStringLiteral("minimumPause")).toInt(); + const int minimumTap = arguments.value(QStringLiteral("minimumTap")).toInt(); + const int maximumTap = arguments.value(QStringLiteral("maximumTap")).toInt(); + const QString outputEvent = arguments.value(QStringLiteral("outputEvent")).toString(); + static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$")); + if (minimumAnchorAge < 0 || minimumAnchorAge > 2000 + || minimumPause < 0 || minimumPause > 2000 + || minimumTap < 0 || minimumTap > maximumTap + || maximumTap < 1 || maximumTap > 2000 + || !eventName.match(outputEvent).hasMatch()) { + KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); + reply.addData(QStringLiteral("message"), QStringLiteral("The requested touchpad settings are invalid.")); + return reply; + } + + return commandReply(controller, + {QStringLiteral("set"), + QStringLiteral("--minimum-anchor-age-ms"), QString::number(minimumAnchorAge), + QStringLiteral("--minimum-pause-ms"), QString::number(minimumPause), + QStringLiteral("--minimum-tap-ms"), QString::number(minimumTap), + QStringLiteral("--maximum-tap-ms"), QString::number(maximumTap), + QStringLiteral("--output-event"), outputEvent}); + } + KAuth::ActionReply fingerprintworkaround(const QVariantMap &arguments) { const QString operation = arguments.value(QStringLiteral("operation")).toString(); diff --git a/fedora-tools-settings/src/se.ajpanton.fedoratools.actions b/fedora-tools-settings/src/se.ajpanton.fedoratools.actions index 973c5ea..909f8d6 100644 --- a/fedora-tools-settings/src/se.ajpanton.fedoratools.actions +++ b/fedora-tools-settings/src/se.ajpanton.fedoratools.actions @@ -7,6 +7,16 @@ Name=Install a Fedora tool Description=Install a package from the Fedora Tools repository Policy=auth_admin +[se.ajpanton.fedoratools.removepackage] +Name=Remove a Fedora tool +Description=Remove an installed Fedora Tools package +Policy=auth_admin + +[se.ajpanton.fedoratools.configuretouchpad] +Name=Configure the touchpad hold-tap gesture +Description=Change system-wide settings for the touchpad hold-tap plugin +Policy=auth_admin + [se.ajpanton.fedoratools.fingerprintworkaround] Name=Change the Plasma fingerprint workaround Description=Install or remove the experimental patched KScreenLocker package diff --git a/fedora-tools-settings/src/toolmodel.cpp b/fedora-tools-settings/src/toolmodel.cpp index 49178db..741a6a1 100644 --- a/fedora-tools-settings/src/toolmodel.cpp +++ b/fedora-tools-settings/src/toolmodel.cpp @@ -42,7 +42,10 @@ QVariant ToolModel::data(const QModelIndex &index, int role) const case UpdateAvailableRole: return tool.updateAvailable; case ConfigurableRole: - return tool.installed && tool.packageName == QStringLiteral("plasma-fingerprint-workaround"); + return tool.installed + && (tool.packageName == QStringLiteral("plasma-fingerprint-workaround") + || tool.packageName == QStringLiteral("plasma-task-group-shortcuts") + || tool.packageName == QStringLiteral("touchpad-hold-tap")); default: return {}; } @@ -83,16 +86,15 @@ void ToolModel::setPackages(const QList &installed, tool.packageName = package.name; tool.availableVersion = package.version; tool.architecture = package.architecture; - tool.summary = package.summary; + if (!tool.installed) { + tool.summary = package.summary; + } tool.available = true; tool.updateAvailable = tool.installed && updates.contains(package.name); } QList merged = tools.values(); std::ranges::sort(merged, [](const Tool &left, const Tool &right) { - if (left.installed != right.installed) { - return left.installed > right.installed; - } return left.packageName < right.packageName; }); @@ -107,3 +109,10 @@ bool ToolModel::mayInstall(const QString &packageName) const return tool.packageName == packageName && tool.available; }); } + +bool ToolModel::mayRemove(const QString &packageName) const +{ + return std::ranges::any_of(m_tools, [&packageName](const Tool &tool) { + return tool.packageName == packageName && tool.installed; + }); +} diff --git a/fedora-tools-settings/src/toolmodel.h b/fedora-tools-settings/src/toolmodel.h index 16af48d..116b85a 100644 --- a/fedora-tools-settings/src/toolmodel.h +++ b/fedora-tools-settings/src/toolmodel.h @@ -46,6 +46,7 @@ public: const QList &available, const QSet &updates = {}); bool mayInstall(const QString &packageName) const; + bool mayRemove(const QString &packageName) const; private: QList m_tools; diff --git a/fedora-tools-settings/src/ui/main.qml b/fedora-tools-settings/src/ui/main.qml index 313c2f0..e4ac736 100644 --- a/fedora-tools-settings/src/ui/main.qml +++ b/fedora-tools-settings/src/ui/main.qml @@ -12,14 +12,25 @@ import org.kde.kirigami as Kirigami KCM.SimpleKCM { id: root - property bool showingFingerprintSettings: false + property string settingsPage: "" implicitWidth: Kirigami.Units.gridUnit * 36 implicitHeight: Kirigami.Units.gridUnit * 30 StackLayout { anchors.fill: parent - currentIndex: root.showingFingerprintSettings ? 1 : 0 + currentIndex: { + if (root.settingsPage === "plasma-fingerprint-workaround") { + return 1 + } + if (root.settingsPage === "plasma-task-group-shortcuts") { + return 2 + } + if (root.settingsPage === "touchpad-hold-tap") { + return 3 + } + return 0 + } ColumnLayout { spacing: Kirigami.Units.largeSpacing @@ -50,7 +61,7 @@ KCM.SimpleKCM { Controls.BusyIndicator { Layout.alignment: Qt.AlignHCenter - visible: kcm.busy + visible: kcm.busy && kcm.activePackage.length === 0 running: visible } @@ -83,6 +94,7 @@ KCM.SimpleKCM { Kirigami.Icon { source: "applications-system" + opacity: toolCard.installed ? 1 : 0 implicitWidth: Kirigami.Units.iconSizes.medium implicitHeight: width } @@ -124,14 +136,96 @@ KCM.SimpleKCM { visible: toolCard.configurable text: i18n("Configure…") enabled: !kcm.busy - onClicked: root.showingFingerprintSettings = true + onClicked: { + kcm.clearMessage() + root.settingsPage = toolCard.packageName + if (root.settingsPage === "plasma-task-group-shortcuts") { + shortcutPage.loadSettings() + } else if (root.settingsPage === "touchpad-hold-tap") { + touchpadPage.loadSettings() + } + } } - Controls.Button { + StackLayout { + id: installControl + visible: toolCard.available && (!toolCard.installed || toolCard.updateAvailable) - text: toolCard.installed ? i18n("Update") : i18n("Install") - enabled: !kcm.busy - onClicked: kcm.installTool(toolCard.packageName) + Layout.minimumWidth: Kirigami.Units.gridUnit * 5 + Layout.preferredWidth: Layout.minimumWidth + Layout.maximumWidth: Layout.minimumWidth + Layout.preferredHeight: installButton.implicitHeight + currentIndex: kcm.activePackage === toolCard.packageName + && kcm.packageOperation === "install" ? 1 : 0 + + Controls.Button { + id: installButton + + text: toolCard.installed ? i18n("Update") : i18n("Install") + enabled: !kcm.busy + onClicked: kcm.installTool(toolCard.packageName) + } + + Item { + implicitWidth: installButton.implicitWidth + implicitHeight: installButton.implicitHeight + + Controls.BusyIndicator { + anchors.centerIn: parent + width: Kirigami.Units.iconSizes.smallMedium + height: width + running: installControl.currentIndex === 1 + } + } + } + + StackLayout { + id: removeControl + + visible: toolCard.installed + Layout.minimumWidth: Kirigami.Units.gridUnit * 5 + Layout.preferredWidth: Layout.minimumWidth + Layout.maximumWidth: Layout.minimumWidth + Layout.preferredHeight: removeButton.implicitHeight + currentIndex: kcm.activePackage === toolCard.packageName + && kcm.packageOperation === "remove" ? 1 : 0 + + Controls.Button { + id: removeButton + + text: i18n("Uninstall") + enabled: !kcm.busy + onClicked: kcm.removeTool(toolCard.packageName) + + contentItem: Controls.Label { + text: removeButton.text + color: Kirigami.Theme.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: Kirigami.Units.cornerRadius + color: Qt.tint( + Kirigami.Theme.backgroundColor, + Qt.alpha(Kirigami.Theme.negativeTextColor, + removeButton.down ? 0.45 : removeButton.hovered ? 0.35 : 0.25)) + border.color: Kirigami.Theme.negativeTextColor + opacity: removeButton.enabled ? 1 : 0.5 + } + } + + Item { + implicitWidth: removeButton.implicitWidth + implicitHeight: removeButton.implicitHeight + + Controls.BusyIndicator { + anchors.centerIn: parent + width: Kirigami.Units.iconSizes.smallMedium + height: width + running: removeControl.currentIndex === 1 + } + } } } } @@ -166,7 +260,7 @@ KCM.SimpleKCM { enabled: !kcm.busy onClicked: { kcm.clearMessage() - root.showingFingerprintSettings = false + root.settingsPage = "" } } @@ -256,6 +350,323 @@ KCM.SimpleKCM { Layout.fillHeight: true } } + + ColumnLayout { + id: shortcutPage + + spacing: Kirigami.Units.largeSpacing + + function loadSettings() { + cycleStart.currentIndex = kcm.shortcutStartWithFirst ? 0 : 1 + initialShift.checked = kcm.shortcutInitialShiftOpensNew + reverseShift.checked = kcm.shortcutShiftCyclesBackward + } + + RowLayout { + Layout.fillWidth: true + + Controls.ToolButton { + text: i18n("Back to tools") + icon.name: "go-previous" + enabled: !kcm.busy + onClicked: { + kcm.clearMessage() + root.settingsPage = "" + } + } + + Kirigami.Heading { + Layout.fillWidth: true + text: i18n("Task-group shortcuts") + level: 2 + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: kcm.message.length > 0 + text: kcm.message + type: kcm.error ? Kirigami.MessageType.Error : Kirigami.MessageType.Positive + } + + Controls.Label { + Layout.fillWidth: true + text: i18n("Plasma normally assigns Meta+number shortcuts to individual Task Manager entries, so every ungrouped window consumes a number. This tool instead assigns each number to an application group in Task Manager order.\n\nPress Meta+number to select a group, then keep Meta held and press the same number again to cycle through that application's windows. Releasing Meta ends the cycle.") + wrapMode: Text.WordWrap + } + + Kirigami.FormLayout { + Layout.fillWidth: true + + Controls.ComboBox { + id: cycleStart + + Kirigami.FormData.label: i18n("New cycle starts with:") + model: [i18n("First window"), i18n("Window after the active one")] + } + + Controls.CheckBox { + id: initialShift + + Kirigami.FormData.label: i18n("Shift on first press:") + text: i18n("Open a new window") + } + + Controls.CheckBox { + id: reverseShift + + Kirigami.FormData.label: i18n("Shift while cycling:") + text: i18n("Cycle backward") + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: initialShift.checked && reverseShift.checked + type: Kirigami.MessageType.Information + text: i18n("With both “Open a new window” and “Cycle backward” enabled, start a backward cycle with Meta+number without Shift. Keep Meta held and use Meta+Shift+number for the following backward steps.") + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + Controls.Button { + text: i18n("Restore Defaults") + icon.name: "edit-undo" + enabled: !kcm.busy + onClicked: kcm.resetShortcutSettings() + } + + Controls.Button { + text: i18n("Save") + icon.name: "document-save" + enabled: !kcm.busy + onClicked: kcm.saveShortcutSettings( + cycleStart.currentIndex === 0, + initialShift.checked, + reverseShift.checked) + } + } + + Controls.Label { + Layout.fillWidth: true + text: i18n("Changes apply the next time a Meta+number shortcut is pressed.") + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + } + + Item { + Layout.fillHeight: true + } + + Connections { + target: kcm + function onToolSettingsChanged() { + shortcutPage.loadSettings() + } + } + } + + ColumnLayout { + id: touchpadPage + + spacing: Kirigami.Units.largeSpacing + + function loadSettings() { + minimumAnchorAge.value = kcm.touchpadMinimumAnchorAge + minimumPause.value = kcm.touchpadMinimumPause + maximumTap.value = kcm.touchpadMaximumTap + minimumTap.value = kcm.touchpadMinimumTap + outputEvent.editText = kcm.touchpadOutputEvent + } + + RowLayout { + Layout.fillWidth: true + + Controls.ToolButton { + text: i18n("Back to tools") + icon.name: "go-previous" + enabled: !kcm.busy + onClicked: { + kcm.clearMessage() + root.settingsPage = "" + } + } + + Kirigami.Heading { + Layout.fillWidth: true + text: i18n("Touchpad hold-tap") + level: 2 + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: kcm.message.length > 0 + text: kcm.message + type: kcm.error ? Kirigami.MessageType.Error : Kirigami.MessageType.Positive + } + + Controls.Label { + Layout.fillWidth: true + text: i18n("Hold one anchor finger on the touchpad, then briefly tap with a second finger.\nMinimum anchor age is how long the first finger must have been touching the touchpad, separating this gesture from an ordinary two-finger tap.\nRequired stationary time is how long the anchor must remain within the movement tolerance before the tap; set it to 0 ms to allow hold-taps while the anchor is moving.\nMinimum tap duration rejects implausibly brief contacts, such as hardware noise that could otherwise produce unintended or duplicate actions.\nMaximum tap duration rejects a second finger that was held rather than tapped.\nOutput event selects the mouse button or single keyboard key emitted for a successful gesture.") + wrapMode: Text.WordWrap + } + + Kirigami.FormLayout { + Layout.fillWidth: true + + Controls.SpinBox { + id: minimumAnchorAge + + Kirigami.FormData.label: i18n("Minimum anchor age:") + from: 0 + to: 2000 + editable: true + textFromValue: function(value, locale) { + return i18n("%1 ms", value) + } + valueFromText: function(text, locale) { + return parseInt(text) + } + } + + Controls.SpinBox { + id: minimumPause + + Kirigami.FormData.label: i18n("Required stationary time:") + from: 0 + to: 2000 + editable: true + textFromValue: function(value, locale) { + return i18n("%1 ms", value) + } + valueFromText: function(text, locale) { + return parseInt(text) + } + } + + Controls.SpinBox { + id: minimumTap + + Kirigami.FormData.label: i18n("Minimum tap duration:") + from: 0 + to: maximumTap.value + editable: true + textFromValue: function(value, locale) { + return i18n("%1 ms", value) + } + valueFromText: function(text, locale) { + return parseInt(text) + } + } + + Controls.SpinBox { + id: maximumTap + + Kirigami.FormData.label: i18n("Maximum tap duration:") + from: 1 + to: 2000 + editable: true + textFromValue: function(value, locale) { + return i18n("%1 ms", value) + } + valueFromText: function(text, locale) { + return parseInt(text) + } + } + + Controls.ComboBox { + id: outputEvent + + Kirigami.FormData.label: i18n("Output event:") + editable: true + model: [ + "BTN_MIDDLE", + "BTN_LEFT", + "BTN_RIGHT", + "BTN_SIDE", + "BTN_EXTRA", + "KEY_ENTER", + "KEY_ESC", + "KEY_SPACE", + "KEY_F13", + "KEY_F14", + "KEY_F15", + "KEY_F16" + ] + } + } + + Controls.Label { + Layout.fillWidth: true + text: i18n("The output is one Linux evdev BTN_* or KEY_* event. The default BTN_MIDDLE produces a middle click.") + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + visible: kcm.busy + + Controls.BusyIndicator { + running: parent.visible + } + + Controls.Label { + text: i18n("Applying change…") + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + Controls.Button { + text: i18n("Restore Defaults") + icon.name: "edit-undo" + enabled: !kcm.busy + onClicked: kcm.resetTouchpadSettings() + } + + Controls.Button { + text: i18n("Save") + icon.name: "document-save" + enabled: !kcm.busy + onClicked: kcm.saveTouchpadSettings( + minimumAnchorAge.value, + minimumPause.value, + minimumTap.value, + maximumTap.value, + outputEvent.editText.trim().toUpperCase()) + } + } + + Controls.Label { + Layout.fillWidth: true + text: i18n("Log out and back in after saving or restoring defaults so KWin reloads the libinput plugin.") + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + } + + Item { + Layout.fillHeight: true + } + + Connections { + target: kcm + function onToolSettingsChanged() { + touchpadPage.loadSettings() + } + } + } } Dialogs.FileDialog { diff --git a/fedora-tools-settings/tests/test-toolmodel.cpp b/fedora-tools-settings/tests/test-toolmodel.cpp index 40694e1..277c74b 100644 --- a/fedora-tools-settings/tests/test-toolmodel.cpp +++ b/fedora-tools-settings/tests/test-toolmodel.cpp @@ -23,6 +23,8 @@ private Q_SLOTS: QCOMPARE(model.data(index, ToolModel::AvailableRole).toBool(), true); QCOMPARE(model.data(index, ToolModel::UpdateAvailableRole).toBool(), true); QCOMPARE(model.mayInstall(QStringLiteral("touchpad-hold-tap")), true); + QCOMPARE(model.mayRemove(QStringLiteral("touchpad-hold-tap")), true); + QCOMPARE(model.mayRemove(QStringLiteral("missing-tool")), false); } void doesNotGuessUpdatesFromDifferentVersionStrings() @@ -44,6 +46,34 @@ private Q_SLOTS: QCOMPARE(model.data(model.index(0), ToolModel::ConfigurableRole).toBool(), true); } + + void sortsAlphabeticallyRegardlessOfInstallState() + { + ToolModel model; + const PackageRecord installed{QStringLiteral("touchpad-hold-tap"), QStringLiteral("1"), QStringLiteral("noarch"), QStringLiteral("Touchpad")}; + const PackageRecord availableFirst{QStringLiteral("plasma-always-show-unlock"), QStringLiteral("1"), QStringLiteral("noarch"), QStringLiteral("Lock screen")}; + const PackageRecord availableLast{QStringLiteral("touchpad-hold-tap"), QStringLiteral("1"), QStringLiteral("noarch"), QStringLiteral("Old touchpad summary")}; + + model.setPackages({installed}, {availableFirst, availableLast}); + + QCOMPARE(model.data(model.index(0), ToolModel::PackageNameRole).toString(), + QStringLiteral("plasma-always-show-unlock")); + QCOMPARE(model.data(model.index(1), ToolModel::PackageNameRole).toString(), + QStringLiteral("touchpad-hold-tap")); + QCOMPARE(model.data(model.index(1), ToolModel::SummaryRole).toString(), + QStringLiteral("Touchpad")); + } + + void makesInstalledToolsWithSettingsConfigurable() + { + ToolModel model; + const PackageRecord shortcuts{QStringLiteral("plasma-task-group-shortcuts"), QStringLiteral("1"), QStringLiteral("x86_64"), QStringLiteral("Shortcuts")}; + const PackageRecord touchpad{QStringLiteral("touchpad-hold-tap"), QStringLiteral("1"), QStringLiteral("noarch"), QStringLiteral("Touchpad")}; + model.setPackages({shortcuts, touchpad}, {shortcuts, touchpad}); + + QCOMPARE(model.data(model.index(0), ToolModel::ConfigurableRole).toBool(), true); + QCOMPARE(model.data(model.index(1), ToolModel::ConfigurableRole).toBool(), true); + } }; QTEST_MAIN(ToolModelTest) diff --git a/plasma-always-show-unlock/plasma-always-show-unlock.spec b/plasma-always-show-unlock/plasma-always-show-unlock.spec index 796abe3..72dc7a9 100644 --- a/plasma-always-show-unlock/plasma-always-show-unlock.spec +++ b/plasma-always-show-unlock/plasma-always-show-unlock.spec @@ -1,7 +1,7 @@ Name: plasma-always-show-unlock Version: 0.1.0 -Release: 9%{?dist} -Summary: Always show the Plasma lock-screen unlock prompt +Release: 10%{?dist} +Summary: Immediately show the Plasma unlock prompt without requiring input License: MIT AND GPL-2.0-or-later URL: https://git.ajpanton.se/ajp_anton/fedora-tools @@ -57,6 +57,9 @@ fi %{_datadir}/plasma-always-show-unlock/LockScreenUi.patch %changelog +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-10 +- Clarify the package summary + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-9 - Advertise the tool to Fedora Tools settings diff --git a/plasma-fingerprint-workaround/plasma-fingerprint-workaround.spec b/plasma-fingerprint-workaround/plasma-fingerprint-workaround.spec index 94dab15..48a7997 100644 --- a/plasma-fingerprint-workaround/plasma-fingerprint-workaround.spec +++ b/plasma-fingerprint-workaround/plasma-fingerprint-workaround.spec @@ -1,7 +1,7 @@ Name: plasma-fingerprint-workaround Version: 0.1.0 -Release: 2%{?dist} -Summary: Opt-in installer for a patched Fedora KScreenLocker +Release: 3%{?dist} +Summary: Opt-in patched KScreenLocker for fingerprint recovery after suspend License: MIT URL: https://git.ajpanton.se/ajp_anton/fedora-tools @@ -51,6 +51,9 @@ install -D -m 0644 %{SOURCE3} \ %{_datadir}/plasma-fingerprint-workaround/payload.conf %changelog +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-3 +- Clarify what the workaround patches + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-2 - Advertise the tool to Fedora Tools settings diff --git a/plasma-task-group-shortcuts/main.cpp b/plasma-task-group-shortcuts/main.cpp index e6fe5a9..33eba8c 100644 --- a/plasma-task-group-shortcuts/main.cpp +++ b/plasma-task-group-shortcuts/main.cpp @@ -36,6 +36,23 @@ constexpr auto configFile = "plasma-org.kde.plasma.desktop-appletsrc"; constexpr auto componentId = "se.ajpanton.plasma-task-group-shortcuts"; constexpr auto stateFile = "plasma-task-group-shortcutsrc"; +struct ShortcutSettings { + bool startWithFirst = true; + bool initialShiftOpensNewInstance = true; + bool shiftCyclesBackward = true; +}; + +ShortcutSettings shortcutSettings() +{ + const auto config = KSharedConfig::openConfig(QString::fromLatin1(stateFile)); + const KConfigGroup group(config, QStringLiteral("Settings")); + return { + group.readEntry("StartWithFirstWindow", true), + group.readEntry("InitialShiftOpensNewInstance", true), + group.readEntry("ShiftCyclesBackward", true), + }; +} + KConfigGroup displacedShortcutGroup(int number) { const auto config = KSharedConfig::openConfig(QString::fromLatin1(stateFile)); @@ -282,15 +299,20 @@ private: return; } + const ShortcutSettings settings = shortcutSettings(); const bool continuing = row == m_lastShortcut; if (!continuing) { - m_cyclePosition = 0; + const bool backwards = shifted + && settings.shiftCyclesBackward + && !settings.initialShiftOpensNewInstance; + m_cyclePosition = initialCyclePosition( + m_model, tasks.at(row), settings.startWithFirst, backwards); } else { - m_cyclePosition += shifted ? -1 : 1; + m_cyclePosition += shifted && settings.shiftCyclesBackward ? -1 : 1; } m_lastShortcut = row; - if (shifted && !continuing) { + if (shifted && !continuing && settings.initialShiftOpensNewInstance) { m_model.requestNewInstance(m_model.index(tasks.at(row), 0)); return; } diff --git a/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec index 6d64408..5160727 100644 --- a/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec +++ b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec @@ -1,7 +1,7 @@ Name: plasma-task-group-shortcuts Version: 0.1.0 -Release: 13%{?dist} -Summary: Application-group shortcuts for the Plasma Task Manager +Release: 15%{?dist} +Summary: Windows-like application-group shortcuts for the Plasma Task Manager License: MIT URL: https://git.ajpanton.se/ajp_anton/fedora-tools @@ -54,6 +54,12 @@ install -Dpm 0644 %{SOURCE2} \ %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop %changelog +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-15 +- Clarify the package summary + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-14 +- Add configurable cycle start and Shift behavior + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-13 - Advertise the tool to Fedora Tools settings diff --git a/plasma-task-group-shortcuts/taskselection.cpp b/plasma-task-group-shortcuts/taskselection.cpp index 36fc644..3f092a4 100644 --- a/plasma-task-group-shortcuts/taskselection.cpp +++ b/plasma-task-group-shortcuts/taskselection.cpp @@ -72,3 +72,22 @@ QVector logicalTaskRows(const QAbstractItemModel &model, bool separateLaunc } return rows; } + +int initialCyclePosition(const QAbstractItemModel &model, int row, bool startWithFirst, bool backwards) +{ + const QModelIndex group = model.index(row, 0); + const int childCount = model.rowCount(group); + if (childCount == 0) { + return 0; + } + + if (!startWithFirst) { + for (int child = 0; child < childCount; ++child) { + if (model.index(child, 0, group).data(AbstractTasksModel::IsActive).toBool()) { + return child + (backwards ? -1 : 1); + } + } + } + + return backwards ? childCount - 1 : 0; +} diff --git a/plasma-task-group-shortcuts/taskselection.h b/plasma-task-group-shortcuts/taskselection.h index 97b0490..59afa66 100644 --- a/plasma-task-group-shortcuts/taskselection.h +++ b/plasma-task-group-shortcuts/taskselection.h @@ -9,3 +9,4 @@ class QAbstractItemModel; QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition); QVector logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers); +int initialCyclePosition(const QAbstractItemModel &model, int row, bool startWithFirst, bool backwards); diff --git a/plasma-task-group-shortcuts/tests/test-taskselection.cpp b/plasma-task-group-shortcuts/tests/test-taskselection.cpp index 45617c9..764b091 100644 --- a/plasma-task-group-shortcuts/tests/test-taskselection.cpp +++ b/plasma-task-group-shortcuts/tests/test-taskselection.cpp @@ -17,6 +17,9 @@ private Q_SLOTS: void returnsTopLevelTask(); void selectsChildByCyclePosition(); void startsWithFirstChild(); + void startsWithLastChildWhenReversing(); + void startsAfterActiveChild(); + void startsBeforeActiveChildWhenReversing(); void keepsSeparateLaunchersAsSlots(); void mergesLauncherWithRunningApplication(); }; @@ -59,6 +62,45 @@ void TaskSelectionTest::startsWithFirstChild() QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0, model.index(0, 0))); } +void TaskSelectionTest::startsWithLastChildWhenReversing() +{ + QStandardItemModel model; + auto *group = new QStandardItem(QStringLiteral("Dolphin")); + group->appendRow(new QStandardItem(QStringLiteral("one"))); + group->appendRow(new QStandardItem(QStringLiteral("two"))); + group->appendRow(new QStandardItem(QStringLiteral("three"))); + model.appendRow(group); + + QCOMPARE(initialCyclePosition(model, 0, true, true), 2); +} + +void TaskSelectionTest::startsAfterActiveChild() +{ + QStandardItemModel model; + auto *group = new QStandardItem(QStringLiteral("Firefox")); + group->appendRow(new QStandardItem(QStringLiteral("one"))); + group->appendRow(new QStandardItem(QStringLiteral("two"))); + group->appendRow(new QStandardItem(QStringLiteral("three"))); + model.appendRow(group); + group->child(1)->setData(true, AbstractTasksModel::IsActive); + + QCOMPARE(initialCyclePosition(model, 0, false, false), 2); +} + +void TaskSelectionTest::startsBeforeActiveChildWhenReversing() +{ + QStandardItemModel model; + auto *group = new QStandardItem(QStringLiteral("Firefox")); + group->appendRow(new QStandardItem(QStringLiteral("one"))); + group->appendRow(new QStandardItem(QStringLiteral("two"))); + group->appendRow(new QStandardItem(QStringLiteral("three"))); + model.appendRow(group); + group->child(0)->setData(true, AbstractTasksModel::IsActive); + + QCOMPARE(initialCyclePosition(model, 0, false, true), -1); + QCOMPARE(taskToActivate(model, 0, -1), model.index(2, 0, model.index(0, 0))); +} + void TaskSelectionTest::keepsSeparateLaunchersAsSlots() { QStandardItemModel model; diff --git a/scripts/build-touchpad-hold-tap-rpm b/scripts/build-touchpad-hold-tap-rpm index 5c777f0..4706f06 100755 --- a/scripts/build-touchpad-hold-tap-rpm +++ b/scripts/build-touchpad-hold-tap-rpm @@ -14,6 +14,12 @@ install -m 0644 \ "$repo_root/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua" \ "$topdir/SOURCES/test-touchpad-hold-tap.lua" install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md" +install -m 0755 \ + "$repo_root/touchpad-hold-tap/touchpad-hold-tap-config" \ + "$topdir/SOURCES/touchpad-hold-tap-config" +install -m 0755 \ + "$repo_root/touchpad-hold-tap/tests/test-touchpad-hold-tap-config" \ + "$topdir/SOURCES/test-touchpad-hold-tap-config" rpmbuild \ --define "_topdir $topdir" \ diff --git a/touchpad-hold-tap/90-touchpad-hold-tap.lua b/touchpad-hold-tap/90-touchpad-hold-tap.lua index 091577e..5995fae 100644 --- a/touchpad-hold-tap/90-touchpad-hold-tap.lua +++ b/touchpad-hold-tap/90-touchpad-hold-tap.lua @@ -1,18 +1,23 @@ -- SPDX-License-Identifier: MIT -- --- Recognize one touchpad contact that has paused before a quick second-finger --- tap. Hide the tapping contact from libinput and emit a middle click instead. +-- Recognize one established touchpad contact followed by a quick second-finger +-- tap. Hide the tapping contact from libinput and emit a configured action. libinput:register({1}) -local MINIMUM_STATIONARY_US = 100 * 1000 -local MAXIMUM_TAP_US = 150 * 1000 +local settings = TOUCHPAD_HOLD_TAP_SETTINGS or {} +local MINIMUM_ANCHOR_AGE_US = (settings.minimum_anchor_age_ms or 100) * 1000 +local MINIMUM_STATIONARY_US = (settings.minimum_pause_ms or 100) * 1000 +local MINIMUM_TAP_US = (settings.minimum_tap_ms or 10) * 1000 +local MAXIMUM_TAP_US = (settings.maximum_tap_ms or 150) * 1000 local MAXIMUM_ANCHOR_MOVEMENT_MM = 1.5 local MAXIMUM_TAPPER_MOVEMENT_MM = 1.0 +local OUTPUT_EVENT_NAME = settings.output_event or "BTN_MIDDLE" +local OUTPUT_USAGE = evdev[OUTPUT_EVENT_NAME] local states = {} -local button_devices = {} -local button_device = nil +local output_devices = {} +local output_device = nil local tool_usages = { { usage = evdev.BTN_TOOL_FINGER, count = 1 }, @@ -131,18 +136,19 @@ local function restore_candidate(device, state, reason) "touchpad-hold-tap: restored candidate (" .. reason .. ")") end -local function emit_middle_click() - if not button_device then +local function emit_action() + if not output_device then libinput:log_error( - "touchpad-hold-tap: no pointer device available for button output") + "touchpad-hold-tap: no device available for " .. + OUTPUT_EVENT_NAME .. " output") return false end - button_device:append_frame({ - { usage = evdev.BTN_MIDDLE, value = 1 }, + output_device:append_frame({ + { usage = OUTPUT_USAGE, value = 1 }, }) - button_device:append_frame({ - { usage = evdev.BTN_MIDDLE, value = 0 }, + output_device:append_frame({ + { usage = OUTPUT_USAGE, value = 0 }, }) return true end @@ -185,8 +191,14 @@ local function handle_tracking_begin(device, state, tracking_id, timestamp) state.anchor_slot = contact.slot elseif state.mode == "anchor" and previous_count == 1 then local anchor = state.contacts[state.anchor_slot] - if anchor and anchor.rest_x and anchor.rest_y and - timestamp - anchor.stationary_since >= MINIMUM_STATIONARY_US then + local old_enough = anchor and + timestamp - anchor.down_time >= + MINIMUM_ANCHOR_AGE_US + local stationary = MINIMUM_STATIONARY_US == 0 or + (anchor and anchor.rest_x and anchor.rest_y and + timestamp - anchor.stationary_since >= + MINIMUM_STATIONARY_US) + if old_enough and stationary then contact.hidden = true state.mode = "candidate" state.candidate_slot = contact.slot @@ -211,7 +223,9 @@ local function handle_tracking_end(device, state, timestamp) if contact.hidden and state.source_slot == state.candidate_slot then local anchor = state.contacts[state.anchor_slot] local duration = timestamp - contact.down_time - local accepted = anchor and duration <= MAXIMUM_TAP_US + local accepted = anchor and + duration >= MINIMUM_TAP_US and + duration <= MAXIMUM_TAP_US state.contacts[state.source_slot] = nil state.candidate_slot = nil @@ -224,11 +238,12 @@ local function handle_tracking_end(device, state, timestamp) end if accepted then - if emit_middle_click() then - libinput:log_debug("touchpad-hold-tap: middle click") + if emit_action() then + libinput:log_debug( + "touchpad-hold-tap: emitted " .. OUTPUT_EVENT_NAME) end else - libinput:log_debug("touchpad-hold-tap: ignored long tap") + libinput:log_debug("touchpad-hold-tap: ignored tap duration") end return false, true end @@ -243,6 +258,12 @@ local function handle_tracking_end(device, state, timestamp) if contact_count(state, false) > 0 then state.mode = "passthrough" end + elseif state.mode == "passthrough" and state.anchor_slot and + state.contacts[state.anchor_slot] and + contact_count(state, false) == 1 then + -- A rejected second contact must not poison the original anchor. + -- It can become eligible again without first being lifted. + state.mode = "anchor" end reset_if_clear(state) return true, false @@ -272,7 +293,7 @@ local function handle_mt_axis(device, state, event, timestamp) remember_axis(contact, event.usage, event.value) if contact.slot == state.anchor_slot then - if state.mode == "candidate" and + if state.mode == "candidate" and MINIMUM_STATIONARY_US > 0 and movement_exceeded_from( state, contact, state.candidate_anchor_x, state.candidate_anchor_y, MAXIMUM_ANCHOR_MOVEMENT_MM) then @@ -373,31 +394,45 @@ end local function device_removed(device) states[device] = nil - button_devices[device] = nil - if button_device == device then - button_device = nil - for candidate in pairs(button_devices) do - button_device = candidate + output_devices[device] = nil + if output_device == device then + output_device = nil + for candidate in pairs(output_devices) do + output_device = candidate break end end end +local function can_emit_output(properties, usages) + if OUTPUT_EVENT_NAME:sub(1, 4) == "BTN_" then + return (properties.ID_INPUT_MOUSE or + properties.ID_INPUT_POINTINGSTICK) and + not properties.ID_INPUT_TOUCHPAD and + usages[evdev.REL_X] and usages[evdev.REL_Y] + end + return properties.ID_INPUT_KEYBOARD +end + local function device_new(device) + if not OUTPUT_USAGE then + libinput:log_error( + "touchpad-hold-tap: unknown output event " .. OUTPUT_EVENT_NAME) + return + end + local properties = device:udev_properties() local usages = device:usages() - -- Clickpads intentionally ignore BTN_MIDDLE from their own event node. - -- Route synthesized buttons through an existing pointer device instead. - if (properties.ID_INPUT_MOUSE or properties.ID_INPUT_POINTINGSTICK) and - not properties.ID_INPUT_TOUCHPAD and - usages[evdev.REL_X] and usages[evdev.REL_Y] then - if not usages[evdev.BTN_MIDDLE] then - device:enable_evdev_usage(evdev.BTN_MIDDLE) + -- Clickpads ignore synthesized buttons on their own event node. Keyboard + -- events likewise belong on a keyboard, so use a suitable existing device. + if can_emit_output(properties, usages) then + if not usages[OUTPUT_USAGE] then + device:enable_evdev_usage(OUTPUT_USAGE) end - button_devices[device] = true - if not button_device then - button_device = device + output_devices[device] = true + if not output_device then + output_device = device end device:connect("device-removed", device_removed) end diff --git a/touchpad-hold-tap/tests/test-touchpad-hold-tap-config b/touchpad-hold-tap/tests/test-touchpad-hold-tap-config new file mode 100644 index 0000000..f4ecc21 --- /dev/null +++ b/touchpad-hold-tap/tests/test-touchpad-hold-tap-config @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# SPDX-License-Identifier: MIT + +set -euo pipefail + +plugin=$1 +configurator=$2 +test_root=$(mktemp -d) +trap 'rm -rf -- "$test_root"' EXIT + +install -Dpm 0644 "$plugin" \ + "$test_root/usr/lib64/libinput/plugins/90-touchpad-hold-tap.lua" + +TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" set \ + --minimum-anchor-age-ms 125 \ + --minimum-pause-ms 175 \ + --minimum-tap-ms 15 \ + --maximum-tap-ms 225 \ + --output-event KEY_F13 + +config="$test_root/etc/touchpad-hold-tap.conf" +override="$test_root/etc/libinput/plugins/90-touchpad-hold-tap.lua" +grep -qx 'MINIMUM_PAUSE_MS=175' "$config" +grep -qx 'MINIMUM_ANCHOR_AGE_MS=125' "$config" +grep -qx 'MINIMUM_TAP_MS=15' "$config" +grep -qx 'MAXIMUM_TAP_MS=225' "$config" +grep -qx 'OUTPUT_EVENT=KEY_F13' "$config" +grep -q 'minimum_pause_ms = 175' "$override" +grep -q 'minimum_anchor_age_ms = 125' "$override" +grep -q 'minimum_tap_ms = 15' "$override" +grep -q 'maximum_tap_ms = 225' "$override" +grep -q 'output_event = "KEY_F13"' "$override" + +TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" reset +[[ ! -e $config ]] +[[ ! -e $override ]] + +printf 'touchpad configurator tests passed\n' diff --git a/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua b/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua index 8f0d9a4..4c2c601 100644 --- a/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua +++ b/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua @@ -5,6 +5,8 @@ local function usage(event_type, code) end evdev = { + BTN_LEFT = usage(1, 272), + BTN_RIGHT = usage(1, 273), BTN_MIDDLE = usage(1, 274), BTN_TOUCH = usage(1, 330), BTN_TOOL_FINGER = usage(1, 325), @@ -20,8 +22,19 @@ evdev = { ABS_MT_POSITION_Y = usage(3, 54), ABS_MT_TOOL_Y = usage(3, 61), ABS_MT_TRACKING_ID = usage(3, 57), + KEY_F13 = usage(1, 183), } +local output_event = arg[2] or "BTN_MIDDLE" +local allow_moving_anchor = arg[3] == "allow-moving-anchor" +local expected_output = evdev[output_event] +if arg[2] or allow_moving_anchor then + TOUCHPAD_HOLD_TAP_SETTINGS = { + output_event = output_event, + minimum_pause_ms = allow_moving_anchor and 0 or nil, + } +end + local plugin_callbacks = {} libinput = { register = function(_, versions) @@ -66,6 +79,8 @@ local function new_device(device_type) return { ID_INPUT_TOUCHPAD = true } elseif device_type == "mouse" then return { ID_INPUT_MOUSE = true } + elseif device_type == "keyboard" then + return { ID_INPUT_KEYBOARD = true } end return {} end @@ -159,12 +174,13 @@ local function up(device, timestamp, slot, count_after) return feed(device, timestamp, frame) end -local button_device = new_device("mouse") +local output_device = new_device(output_event:sub(1, 4) == "KEY_" and + "keyboard" or "mouse") -local function click_count() +local function action_count() local count = 0 - for _, frame in ipairs(button_device.appended) do - if contains(frame, evdev.BTN_MIDDLE, 1) then + for _, frame in ipairs(output_device.appended) do + if contains(frame, expected_output, 1) then count = count + 1 end end @@ -181,7 +197,7 @@ function tests.hold_tap_emits_middle_and_hides_tapper() assert(not contains(second_down, evdev.BTN_TOOL_DOUBLETAP, 1)) local second_up = up(device, 260000, 1, 1) assert(not contains(second_up, evdev.ABS_MT_TRACKING_ID, -1)) - assert(click_count() == 1) + assert(action_count() == 1) assert(#device.appended == 0) up(device, 440000, 0, 0) end @@ -193,7 +209,7 @@ function tests.hold_doubletap_emits_two_middle_clicks() up(device, 250000, 1, 1) down(device, 360000, 1, 22, 1400, 1000, 2) up(device, 420000, 1, 1) - assert(click_count() == 2) + assert(action_count() == 2) up(device, 600000, 0, 0) end @@ -205,7 +221,7 @@ function tests.ordinary_two_finger_tap_passes_through() assert(contains(second, evdev.BTN_TOOL_DOUBLETAP, 1)) up(device, 70000, 1, 1) up(device, 80000, 0, 0) - assert(click_count() == 0) + assert(action_count() == 0) end function tests.long_stationary_tap_is_consumed_without_click() @@ -214,10 +230,19 @@ function tests.long_stationary_tap_is_consumed_without_click() down(device, 200000, 1, 41, 1400, 1000, 2) local second_up = up(device, 360001, 1, 1) assert(not contains(second_up, evdev.ABS_MT_TRACKING_ID, -1)) - assert(click_count() == 0) + assert(action_count() == 0) up(device, 500000, 0, 0) end +function tests.implausibly_short_contact_is_consumed_without_click() + local device = new_device("touchpad") + down(device, 0, 0, 45, 1000, 1000, 1) + down(device, 200000, 1, 46, 1400, 1000, 2) + up(device, 201000, 1, 1) + assert(action_count() == 0) + up(device, 400000, 0, 0) +end + function tests.tapper_movement_restores_normal_input() local device = new_device("touchpad") down(device, 0, 0, 50, 1000, 1000, 1) @@ -231,7 +256,7 @@ function tests.tapper_movement_restores_normal_input() assert(not contains(moved, evdev.ABS_MT_POSITION_X, 1430)) up(device, 260000, 1, 1) up(device, 400000, 0, 0) - assert(click_count() == 0) + assert(action_count() == 0) end function tests.anchor_movement_prevents_recognition() @@ -239,10 +264,11 @@ function tests.anchor_movement_prevents_recognition() down(device, 0, 0, 60, 1000, 1000, 1) feed(device, 150000, { event(evdev.ABS_MT_POSITION_X, 1040) }) local second = down(device, 200000, 1, 61, 1400, 1000, 2) - assert(contains(second, evdev.ABS_MT_TRACKING_ID, 61)) + assert(contains(second, evdev.ABS_MT_TRACKING_ID, 61) ~= + allow_moving_anchor) up(device, 250000, 1, 1) up(device, 400000, 0, 0) - assert(click_count() == 0) + assert(action_count() == (allow_moving_anchor and 1 or 0)) end function tests.anchor_movement_during_tap_restores_normal_input() @@ -253,12 +279,54 @@ function tests.anchor_movement_during_tap_restores_normal_input() event(evdev.ABS_MT_SLOT, 0), event(evdev.ABS_MT_POSITION_X, 1040), }) - assert(#device.prepended == 1) - assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 63)) + assert(#device.prepended == (allow_moving_anchor and 0 or 1)) + if not allow_moving_anchor then + assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 63)) + end assert(contains(moved, evdev.ABS_MT_POSITION_X, 1040)) up(device, 260000, 1, 1) up(device, 400000, 0, 0) - assert(click_count() == 0) + assert(action_count() == (allow_moving_anchor and 1 or 0)) +end + +function tests.rejected_tap_does_not_poison_anchor() + local device = new_device("touchpad") + down(device, 0, 0, 64, 1000, 1000, 1) + + -- This near-simultaneous contact is passed through as an ordinary + -- two-finger interaction. + local early = down(device, 10000, 1, 65, 1400, 1000, 2) + assert(contains(early, evdev.ABS_MT_TRACKING_ID, 65)) + up(device, 60000, 1, 1) + + -- The same anchor can recognize a later tap without being lifted first. + local later = down(device, 200000, 1, 66, 1400, 1000, 2) + assert(not contains(later, evdev.ABS_MT_TRACKING_ID, 66)) + up(device, 250000, 1, 1) + assert(action_count() == 1) + up(device, 300000, 0, 0) +end + +function tests.tap_while_moving_does_not_poison_anchor() + if allow_moving_anchor then + return + end + + local device = new_device("touchpad") + down(device, 0, 0, 67, 1000, 1000, 1) + feed(device, 150000, { + event(evdev.ABS_MT_SLOT, 0), + event(evdev.ABS_MT_POSITION_X, 1040), + }) + local moving_tap = down(device, 160000, 1, 68, 1400, 1000, 2) + assert(contains(moving_tap, evdev.ABS_MT_TRACKING_ID, 68)) + up(device, 210000, 1, 1) + + local later = down(device, 320000, 1, 69, 1400, 1000, 2) + assert(not contains(later, evdev.ABS_MT_TRACKING_ID, 69)) + up(device, 370000, 1, 1) + assert(action_count() == 1) + up(device, 400000, 0, 0) end function tests.anchor_can_move_pause_and_tap_repeatedly() @@ -272,7 +340,7 @@ function tests.anchor_can_move_pause_and_tap_repeatedly() }) down(device, 650000, 1, 66, 1500, 1100, 2) up(device, 700000, 1, 1) - assert(click_count() == 1) + assert(action_count() == 1) feed(device, 800000, { event(evdev.ABS_MT_SLOT, 0), @@ -281,7 +349,7 @@ function tests.anchor_can_move_pause_and_tap_repeatedly() }) down(device, 950000, 1, 67, 2000, 1200, 2) up(device, 1000000, 1, 1) - assert(click_count() == 2) + assert(action_count() == 2) up(device, 1100000, 0, 0) end @@ -297,7 +365,7 @@ function tests.third_contact_restores_candidate() up(device, 260000, 2, 2) up(device, 280000, 1, 1) up(device, 400000, 0, 0) - assert(click_count() == 0) + assert(action_count() == 0) end function tests.two_finger_hold_tap_remains_passthrough() @@ -309,7 +377,7 @@ function tests.two_finger_hold_tap_remains_passthrough() up(device, 360000, 2, 2) up(device, 500000, 1, 1) up(device, 510000, 0, 0) - assert(click_count() == 0) + assert(action_count() == 0) assert(#device.prepended == 0) end @@ -320,8 +388,8 @@ function tests.non_touchpad_is_ignored() end function tests.pointer_device_is_used_for_button_output() - assert(button_device.enabled[evdev.BTN_MIDDLE]) - assert(button_device.callbacks["evdev-frame"] == nil) + assert(output_device.enabled[expected_output]) + assert(output_device.callbacks["evdev-frame"] == nil) end local names = {} @@ -330,7 +398,7 @@ for name in pairs(tests) do end table.sort(names) for _, name in ipairs(names) do - button_device.appended = {} + output_device.appended = {} tests[name]() print("ok - " .. name) end diff --git a/touchpad-hold-tap/touchpad-hold-tap-config b/touchpad-hold-tap/touchpad-hold-tap-config new file mode 100644 index 0000000..a197550 --- /dev/null +++ b/touchpad-hold-tap/touchpad-hold-tap-config @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +# SPDX-License-Identifier: MIT + +set -euo pipefail + +root=${TOUCHPAD_HOLD_TAP_TEST_ROOT:-} +config_file="$root/etc/touchpad-hold-tap.conf" +override_file="$root/etc/libinput/plugins/90-touchpad-hold-tap.lua" +template_file="$root/usr/lib64/libinput/plugins/90-touchpad-hold-tap.lua" +if [[ ! -e $template_file ]]; then + template_file="$root/usr/lib/libinput/plugins/90-touchpad-hold-tap.lua" +fi + +minimum_anchor_age_ms=100 +minimum_pause_ms=100 +minimum_tap_ms=10 +maximum_tap_ms=150 +output_event=BTN_MIDDLE + +die() +{ + printf 'touchpad-hold-tap-config: %s\n' "$*" >&2 + exit 1 +} + +require_root() +{ + if [[ -z $root && $EUID -ne 0 ]]; then + die "this operation must be run as root" + fi +} + +validate() +{ + [[ $minimum_anchor_age_ms =~ ^[0-9]+$ ]] || die "minimum anchor age must be an integer" + [[ $minimum_pause_ms =~ ^[0-9]+$ ]] || die "minimum pause must be an integer" + [[ $minimum_tap_ms =~ ^[0-9]+$ ]] || die "minimum tap duration must be an integer" + [[ $maximum_tap_ms =~ ^[0-9]+$ ]] || die "maximum tap duration must be an integer" + (( minimum_anchor_age_ms <= 2000 )) || die "minimum anchor age must not exceed 2000 ms" + (( minimum_pause_ms <= 2000 )) || die "minimum pause must not exceed 2000 ms" + (( maximum_tap_ms >= 1 && minimum_tap_ms <= maximum_tap_ms && maximum_tap_ms <= 2000 )) || \ + die "tap durations must be between 0 and 2000 ms, with a positive maximum and minimum not exceeding maximum" + [[ $output_event =~ ^(BTN|KEY)_[A-Z0-9_]+$ ]] || \ + die "output event must be an evdev BTN_* or KEY_* name" +} + +read_config() +{ + if [[ ! -r $config_file ]]; then + return 0 + fi + + while IFS='=' read -r key value; do + case $key in + MINIMUM_ANCHOR_AGE_MS) minimum_anchor_age_ms=$value ;; + MINIMUM_PAUSE_MS) minimum_pause_ms=$value ;; + MINIMUM_TAP_MS) minimum_tap_ms=$value ;; + MAXIMUM_TAP_MS) maximum_tap_ms=$value ;; + OUTPUT_EVENT) output_event=$value ;; + ''|'#'*) ;; + *) die "unknown setting: $key" ;; + esac + done < "$config_file" + validate +} + +write_config() +{ + mkdir -p "$(dirname -- "$config_file")" + local temporary + temporary=$(mktemp "${config_file}.XXXXXX") + trap 'rm -f -- "$temporary"' RETURN + printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \ + "$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \ + "$output_event" > "$temporary" + chmod 0644 "$temporary" + mv -f -- "$temporary" "$config_file" + trap - RETURN +} + +write_override() +{ + [[ -r $template_file ]] || die "installed plugin was not found" + mkdir -p "$(dirname -- "$override_file")" + local temporary + temporary=$(mktemp "${override_file}.XXXXXX") + trap 'rm -f -- "$temporary"' RETURN + awk -v age="$minimum_anchor_age_ms" -v pause="$minimum_pause_ms" \ + -v mintap="$minimum_tap_ms" \ + -v maxtap="$maximum_tap_ms" \ + -v output="$output_event" ' + /^local settings = / { + print "local settings = {" + print " minimum_anchor_age_ms = " age "," + print " minimum_pause_ms = " pause "," + print " minimum_tap_ms = " mintap "," + print " maximum_tap_ms = " maxtap "," + print " output_event = \"" output "\"," + print "}" + replaced = 1 + next + } + { print } + END { if (!replaced) exit 1 } + ' "$template_file" > "$temporary" || die "installed plugin has an unsupported format" + chmod 0644 "$temporary" + mv -f -- "$temporary" "$override_file" + trap - RETURN +} + +usage() +{ + cat <<'EOF' +Usage: + touchpad-hold-tap-config show + touchpad-hold-tap-config set [--minimum-anchor-age-ms N] [--minimum-pause-ms N] [--minimum-tap-ms N] [--maximum-tap-ms N] [--output-event NAME] + touchpad-hold-tap-config reset + touchpad-hold-tap-config refresh +EOF +} + +command=${1:-} +case $command in + show) + [[ $# -eq 1 ]] || die "show takes no arguments" + read_config + printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \ + "$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \ + "$output_event" + ;; + set) + shift + read_config + while [[ $# -gt 0 ]]; do + case $1 in + --minimum-anchor-age-ms) + [[ $# -ge 2 ]] || die "$1 requires a value" + minimum_anchor_age_ms=$2 + shift 2 + ;; + --minimum-pause-ms) + [[ $# -ge 2 ]] || die "$1 requires a value" + minimum_pause_ms=$2 + shift 2 + ;; + --minimum-tap-ms) + [[ $# -ge 2 ]] || die "$1 requires a value" + minimum_tap_ms=$2 + shift 2 + ;; + --maximum-tap-ms) + [[ $# -ge 2 ]] || die "$1 requires a value" + maximum_tap_ms=$2 + shift 2 + ;; + --output-event) + [[ $# -ge 2 ]] || die "$1 requires a value" + output_event=$2 + shift 2 + ;; + *) die "unknown option: $1" ;; + esac + done + require_root + validate + write_config + write_override + ;; + reset) + [[ $# -eq 1 ]] || die "reset takes no arguments" + require_root + rm -f -- "$config_file" "$override_file" + ;; + refresh) + [[ $# -eq 1 ]] || die "refresh takes no arguments" + require_root + if [[ -e $config_file ]]; then + read_config + write_override + else + rm -f -- "$override_file" + fi + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/touchpad-hold-tap/touchpad-hold-tap.spec b/touchpad-hold-tap/touchpad-hold-tap.spec index fa21551..a6ed1cd 100644 --- a/touchpad-hold-tap/touchpad-hold-tap.spec +++ b/touchpad-hold-tap/touchpad-hold-tap.spec @@ -1,6 +1,6 @@ Name: touchpad-hold-tap Version: 0.1.0 -Release: 6%{?dist} +Release: 9%{?dist} Summary: Hold-tap middle-click gesture for touchpads License: MIT @@ -9,6 +9,8 @@ Source0: 90-touchpad-hold-tap.lua Source1: LICENSE Source2: test-touchpad-hold-tap.lua Source3: README.md +Source4: touchpad-hold-tap-config +Source5: test-touchpad-hold-tap-config BuildRequires: lua Requires: libinput >= 1.30 @@ -25,10 +27,15 @@ gestures. Other touchpad input continues through libinput normally. %check lua %{SOURCE2} %{SOURCE0} +lua %{SOURCE2} %{SOURCE0} KEY_F13 +lua %{SOURCE2} %{SOURCE0} BTN_MIDDLE allow-moving-anchor +bash %{SOURCE5} %{SOURCE0} %{SOURCE4} %install install -Dpm 0644 %{SOURCE0} \ %{buildroot}%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua +install -Dpm 0755 %{SOURCE4} \ + %{buildroot}%{_bindir}/touchpad-hold-tap-config install -Dpm 0644 %{SOURCE1} \ %{buildroot}%{_licensedir}/%{name}/LICENSE install -Dpm 0644 %{SOURCE3} \ @@ -37,9 +44,29 @@ install -Dpm 0644 %{SOURCE3} \ %files %license %{_licensedir}/%{name}/LICENSE %doc %{_docdir}/%{name}/README.md +%{_bindir}/touchpad-hold-tap-config %{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua +%posttrans +%{_bindir}/touchpad-hold-tap-config refresh || : + +%postun +if [ "$1" -eq 0 ]; then + rm -f %{_sysconfdir}/touchpad-hold-tap.conf \ + %{_sysconfdir}/libinput/plugins/90-touchpad-hold-tap.lua +fi + %changelog +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-9 +- Separate minimum anchor age from the optional stationary period +- Allow a held anchor to recover after rejected taps + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-8 +- Add a configurable minimum tap duration + +* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-7 +- Add persistent timing and output-event configuration + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-6 - Advertise the tool to Fedora Tools settings