Add per-tool settings and gesture customization

This commit is contained in:
ajp_anton
2026-09-05 05:02:34 +00:00
parent d6f30c5735
commit 38142b9499
26 changed files with 1383 additions and 83 deletions
+11 -1
View File
@@ -35,7 +35,8 @@ systems, architectures, or distributions may be limited.
The optional `fedora-tools-settings` package adds a **Fedora Tools** page to The optional `fedora-tools-settings` package adds a **Fedora Tools** page to
Plasma System Settings. It discovers tools from this RPM repository, shows Plasma System Settings. It discovers tools from this RPM repository, shows
which are installed, and can install available tools after administrator 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 ### 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, Repeating the tap while holding the first finger produces repeated clicks,
including double middle 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 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 that enables libinput plugins. Log out and back in after installation or an
update so the compositor reloads the plugin. 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 application starts** determines whether that separate launcher remains while
the application is running. Other groups retain the Task Manager's sort order. 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 If application grouping is disabled—or an application is excluded from
grouping—each of its windows remains a separate shortcut position. **Only group 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 when the Task Manager is full** does not alter shortcut groups; that would make
+1 -1
View File
@@ -16,7 +16,7 @@ include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE) include(KDECompilerSettings NO_POLICY_SCOPE)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Quick Test) 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) add_subdirectory(src)
if(BUILD_TESTING) if(BUILD_TESTING)
+5
View File
@@ -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 package names that independently resolve to that capability in the
`fedora-tools` repository. `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 Installing this module does not install or activate any other tool. Tools
remain usable without the module. remain usable without the module.
@@ -1,6 +1,6 @@
Name: fedora-tools-settings Name: fedora-tools-settings
Version: 0.1.0 Version: 0.1.0
Release: 1%{?dist} Release: 5%{?dist}
Summary: Plasma System Settings module for Fedora Tools Summary: Plasma System Settings module for Fedora Tools
License: MIT License: MIT
@@ -14,6 +14,7 @@ BuildRequires: extra-cmake-modules
BuildRequires: gcc-c++ BuildRequires: gcc-c++
BuildRequires: kf6-kauth-devel BuildRequires: kf6-kauth-devel
BuildRequires: kf6-kcmutils-devel BuildRequires: kf6-kcmutils-devel
BuildRequires: kf6-kconfig-devel
BuildRequires: kf6-ki18n-devel BuildRequires: kf6-ki18n-devel
BuildRequires: kf6-kirigami-devel BuildRequires: kf6-kirigami-devel
BuildRequires: qt6-qtdeclarative-devel BuildRequires: qt6-qtdeclarative-devel
@@ -58,5 +59,20 @@ install -Dpm 0644 %{SOURCE2} \
%{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy %{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-1
- Initial package - Initial package
+1
View File
@@ -13,6 +13,7 @@ target_link_libraries(kcm_fedora_tools PRIVATE
Qt6::Core Qt6::Core
Qt6::Quick Qt6::Quick
KF6::AuthCore KF6::AuthCore
KF6::ConfigCore
KF6::CoreAddons KF6::CoreAddons
KF6::I18n KF6::I18n
KF6::KCMUtilsQuick KF6::KCMUtilsQuick
@@ -4,10 +4,14 @@
#include <KAuth/Action> #include <KAuth/Action>
#include <KAuth/ExecuteJob> #include <KAuth/ExecuteJob>
#include <KConfigGroup>
#include <KJob> #include <KJob>
#include <KLocalizedString> #include <KLocalizedString>
#include <KPluginFactory> #include <KPluginFactory>
#include <KSharedConfig>
#include <QFile>
#include <QRegularExpression>
#include <QTimer> #include <QTimer>
#include <algorithm> #include <algorithm>
@@ -33,8 +37,10 @@ FedoraToolsKcm::FedoraToolsKcm(QObject *parent, const KPluginMetaData &data)
} }
setMessage(i18n("Could not start %1.", m_query.program()), true); setMessage(i18n("Could not start %1.", m_query.program()), true);
m_stage = QueryStage::Idle; m_stage = QueryStage::Idle;
setPackageOperation({}, {});
setBusy(false); setBusy(false);
}); });
loadToolSettings();
QTimer::singleShot(0, this, [this] { QTimer::singleShot(0, this, [this] {
refresh(); refresh();
}); });
@@ -65,6 +71,56 @@ bool FedoraToolsKcm::fingerprintEnabled() const
return m_fingerprintEnabled; 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) void FedoraToolsKcm::refresh(bool refreshMetadata)
{ {
if (m_busy) { if (m_busy) {
@@ -98,12 +154,107 @@ void FedoraToolsKcm::installTool(const QString &packageName)
return; return;
} }
setPackageOperation(packageName, QStringLiteral("install"));
runAuthorizedAction(QStringLiteral("installpackage"), runAuthorizedAction(QStringLiteral("installpackage"),
{{QStringLiteral("packageName"), packageName}}, {{QStringLiteral("packageName"), packageName}},
i18n("%1 is now installed.", packageName), i18n("%1 is now installed.", packageName),
true); 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) void FedoraToolsKcm::enableFingerprintWorkaround(const QUrl &rpmFile, bool force)
{ {
if (m_busy) { if (m_busy) {
@@ -155,6 +306,7 @@ void FedoraToolsKcm::queryFinished(int exitCode, QProcess::ExitStatus exitStatus
if (exitStatus != QProcess::NormalExit) { if (exitStatus != QProcess::NormalExit) {
setMessage(i18n("Package discovery terminated unexpectedly."), true); setMessage(i18n("Package discovery terminated unexpectedly."), true);
m_stage = QueryStage::Idle; m_stage = QueryStage::Idle;
setPackageOperation({}, {});
setBusy(false); setBusy(false);
return; 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); setMessage(details.isEmpty() ? i18n("The Fedora Tools repository is unavailable.") : details, true);
updateFingerprintStatus(); updateFingerprintStatus();
m_stage = QueryStage::Idle; m_stage = QueryStage::Idle;
setPackageOperation({}, {});
setBusy(false); setBusy(false);
return; return;
} }
@@ -244,6 +397,7 @@ void FedoraToolsKcm::finishRefresh()
m_tools.setPackages(m_installedPackages, m_availablePackages, m_updates); m_tools.setPackages(m_installedPackages, m_availablePackages, m_updates);
updateFingerprintStatus(); updateFingerprintStatus();
m_stage = QueryStage::Idle; m_stage = QueryStage::Idle;
setPackageOperation({}, {});
setBusy(false); 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 helperMessage = executeJob ? executeJob->data().value(QStringLiteral("message")).toString() : QString();
const QString errorMessage = helperMessage.isEmpty() ? job->errorString() : helperMessage; const QString errorMessage = helperMessage.isEmpty() ? job->errorString() : helperMessage;
setMessage(errorMessage, true); setMessage(errorMessage, true);
setPackageOperation({}, {});
setBusy(false); setBusy(false);
return; return;
} }
@@ -283,6 +438,7 @@ void FedoraToolsKcm::actionFinished(KJob *job, const QString &successMessage, bo
refresh(); refresh();
} else { } else {
updateFingerprintStatus(); updateFingerprintStatus();
loadToolSettings();
setBusy(false); setBusy(false);
} }
setMessage(successMessage); setMessage(successMessage);
@@ -297,6 +453,16 @@ void FedoraToolsKcm::setBusy(bool busy)
Q_EMIT busyChanged(); 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) void FedoraToolsKcm::setMessage(const QString &message, bool error)
{ {
if (m_message == message && m_error == 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<QByteArray> 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" #include "fedoratoolskcm.moc"
@@ -21,6 +21,16 @@ class FedoraToolsKcm : public KQuickConfigModule
Q_PROPERTY(QString message READ message NOTIFY messageChanged) Q_PROPERTY(QString message READ message NOTIFY messageChanged)
Q_PROPERTY(bool error READ error NOTIFY messageChanged) Q_PROPERTY(bool error READ error NOTIFY messageChanged)
Q_PROPERTY(bool fingerprintEnabled READ fingerprintEnabled NOTIFY fingerprintEnabledChanged) 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: public:
FedoraToolsKcm(QObject *parent, const KPluginMetaData &data); FedoraToolsKcm(QObject *parent, const KPluginMetaData &data);
@@ -30,10 +40,25 @@ public:
QString message() const; QString message() const;
bool error() const; bool error() const;
bool fingerprintEnabled() 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 refresh(bool refreshMetadata = false);
Q_INVOKABLE void clearMessage(); Q_INVOKABLE void clearMessage();
Q_INVOKABLE void installTool(const QString &packageName); 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 enableFingerprintWorkaround(const QUrl &rpmFile, bool force);
Q_INVOKABLE void disableFingerprintWorkaround(); Q_INVOKABLE void disableFingerprintWorkaround();
@@ -41,6 +66,8 @@ Q_SIGNALS:
void busyChanged(); void busyChanged();
void messageChanged(); void messageChanged();
void fingerprintEnabledChanged(); void fingerprintEnabledChanged();
void packageOperationChanged();
void toolSettingsChanged();
private: private:
enum class QueryStage { enum class QueryStage {
@@ -57,8 +84,10 @@ private:
void runAuthorizedAction(const QString &name, const QVariantMap &arguments, const QString &successMessage, bool refreshTools); void runAuthorizedAction(const QString &name, const QVariantMap &arguments, const QString &successMessage, bool refreshTools);
void actionFinished(KJob *job, const QString &successMessage, bool refreshTools); void actionFinished(KJob *job, const QString &successMessage, bool refreshTools);
void setBusy(bool busy); void setBusy(bool busy);
void setPackageOperation(const QString &packageName, const QString &operation);
void setMessage(const QString &message, bool error = false); void setMessage(const QString &message, bool error = false);
void updateFingerprintStatus(); void updateFingerprintStatus();
void loadToolSettings();
ToolModel m_tools; ToolModel m_tools;
QProcess m_query; QProcess m_query;
@@ -70,5 +99,15 @@ private:
bool m_busy = false; bool m_busy = false;
bool m_error = false; bool m_error = false;
bool m_fingerprintEnabled = false; bool m_fingerprintEnabled = false;
QString m_activePackage;
QString m_packageOperation;
QString m_message; 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");
}; };
+102
View File
@@ -8,6 +8,7 @@
#include <QFileInfo> #include <QFileInfo>
#include <QObject> #include <QObject>
#include <QProcess> #include <QProcess>
#include <QRegularExpression>
namespace namespace
{ {
@@ -71,6 +72,37 @@ bool isPublishedTool(const QString &packageName)
} }
return false; 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 class FedoraToolsHelper : public QObject
@@ -94,6 +126,76 @@ public Q_SLOTS:
packageName}); 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) KAuth::ActionReply fingerprintworkaround(const QVariantMap &arguments)
{ {
const QString operation = arguments.value(QStringLiteral("operation")).toString(); const QString operation = arguments.value(QStringLiteral("operation")).toString();
@@ -7,6 +7,16 @@ Name=Install a Fedora tool
Description=Install a package from the Fedora Tools repository Description=Install a package from the Fedora Tools repository
Policy=auth_admin 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] [se.ajpanton.fedoratools.fingerprintworkaround]
Name=Change the Plasma fingerprint workaround Name=Change the Plasma fingerprint workaround
Description=Install or remove the experimental patched KScreenLocker package Description=Install or remove the experimental patched KScreenLocker package
+14 -5
View File
@@ -42,7 +42,10 @@ QVariant ToolModel::data(const QModelIndex &index, int role) const
case UpdateAvailableRole: case UpdateAvailableRole:
return tool.updateAvailable; return tool.updateAvailable;
case ConfigurableRole: 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: default:
return {}; return {};
} }
@@ -83,16 +86,15 @@ void ToolModel::setPackages(const QList<PackageRecord> &installed,
tool.packageName = package.name; tool.packageName = package.name;
tool.availableVersion = package.version; tool.availableVersion = package.version;
tool.architecture = package.architecture; tool.architecture = package.architecture;
tool.summary = package.summary; if (!tool.installed) {
tool.summary = package.summary;
}
tool.available = true; tool.available = true;
tool.updateAvailable = tool.installed && updates.contains(package.name); tool.updateAvailable = tool.installed && updates.contains(package.name);
} }
QList<Tool> merged = tools.values(); QList<Tool> merged = tools.values();
std::ranges::sort(merged, [](const Tool &left, const Tool &right) { 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; return left.packageName < right.packageName;
}); });
@@ -107,3 +109,10 @@ bool ToolModel::mayInstall(const QString &packageName) const
return tool.packageName == packageName && tool.available; 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;
});
}
+1
View File
@@ -46,6 +46,7 @@ public:
const QList<PackageRecord> &available, const QList<PackageRecord> &available,
const QSet<QString> &updates = {}); const QSet<QString> &updates = {});
bool mayInstall(const QString &packageName) const; bool mayInstall(const QString &packageName) const;
bool mayRemove(const QString &packageName) const;
private: private:
QList<Tool> m_tools; QList<Tool> m_tools;
+420 -9
View File
@@ -12,14 +12,25 @@ import org.kde.kirigami as Kirigami
KCM.SimpleKCM { KCM.SimpleKCM {
id: root id: root
property bool showingFingerprintSettings: false property string settingsPage: ""
implicitWidth: Kirigami.Units.gridUnit * 36 implicitWidth: Kirigami.Units.gridUnit * 36
implicitHeight: Kirigami.Units.gridUnit * 30 implicitHeight: Kirigami.Units.gridUnit * 30
StackLayout { StackLayout {
anchors.fill: parent 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 { ColumnLayout {
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
@@ -50,7 +61,7 @@ KCM.SimpleKCM {
Controls.BusyIndicator { Controls.BusyIndicator {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
visible: kcm.busy visible: kcm.busy && kcm.activePackage.length === 0
running: visible running: visible
} }
@@ -83,6 +94,7 @@ KCM.SimpleKCM {
Kirigami.Icon { Kirigami.Icon {
source: "applications-system" source: "applications-system"
opacity: toolCard.installed ? 1 : 0
implicitWidth: Kirigami.Units.iconSizes.medium implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: width implicitHeight: width
} }
@@ -124,14 +136,96 @@ KCM.SimpleKCM {
visible: toolCard.configurable visible: toolCard.configurable
text: i18n("Configure…") text: i18n("Configure…")
enabled: !kcm.busy 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) visible: toolCard.available && (!toolCard.installed || toolCard.updateAvailable)
text: toolCard.installed ? i18n("Update") : i18n("Install") Layout.minimumWidth: Kirigami.Units.gridUnit * 5
enabled: !kcm.busy Layout.preferredWidth: Layout.minimumWidth
onClicked: kcm.installTool(toolCard.packageName) 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 enabled: !kcm.busy
onClicked: { onClicked: {
kcm.clearMessage() kcm.clearMessage()
root.showingFingerprintSettings = false root.settingsPage = ""
} }
} }
@@ -256,6 +350,323 @@ KCM.SimpleKCM {
Layout.fillHeight: true 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 { Dialogs.FileDialog {
@@ -23,6 +23,8 @@ private Q_SLOTS:
QCOMPARE(model.data(index, ToolModel::AvailableRole).toBool(), true); QCOMPARE(model.data(index, ToolModel::AvailableRole).toBool(), true);
QCOMPARE(model.data(index, ToolModel::UpdateAvailableRole).toBool(), true); QCOMPARE(model.data(index, ToolModel::UpdateAvailableRole).toBool(), true);
QCOMPARE(model.mayInstall(QStringLiteral("touchpad-hold-tap")), 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() void doesNotGuessUpdatesFromDifferentVersionStrings()
@@ -44,6 +46,34 @@ private Q_SLOTS:
QCOMPARE(model.data(model.index(0), ToolModel::ConfigurableRole).toBool(), true); 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) QTEST_MAIN(ToolModelTest)
@@ -1,7 +1,7 @@
Name: plasma-always-show-unlock Name: plasma-always-show-unlock
Version: 0.1.0 Version: 0.1.0
Release: 9%{?dist} Release: 10%{?dist}
Summary: Always show the Plasma lock-screen unlock prompt Summary: Immediately show the Plasma unlock prompt without requiring input
License: MIT AND GPL-2.0-or-later License: MIT AND GPL-2.0-or-later
URL: https://git.ajpanton.se/ajp_anton/fedora-tools URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -57,6 +57,9 @@ fi
%{_datadir}/plasma-always-show-unlock/LockScreenUi.patch %{_datadir}/plasma-always-show-unlock/LockScreenUi.patch
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-9
- Advertise the tool to Fedora Tools settings - Advertise the tool to Fedora Tools settings
@@ -1,7 +1,7 @@
Name: plasma-fingerprint-workaround Name: plasma-fingerprint-workaround
Version: 0.1.0 Version: 0.1.0
Release: 2%{?dist} Release: 3%{?dist}
Summary: Opt-in installer for a patched Fedora KScreenLocker Summary: Opt-in patched KScreenLocker for fingerprint recovery after suspend
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -51,6 +51,9 @@ install -D -m 0644 %{SOURCE3} \
%{_datadir}/plasma-fingerprint-workaround/payload.conf %{_datadir}/plasma-fingerprint-workaround/payload.conf
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-2
- Advertise the tool to Fedora Tools settings - Advertise the tool to Fedora Tools settings
+25 -3
View File
@@ -36,6 +36,23 @@ constexpr auto configFile = "plasma-org.kde.plasma.desktop-appletsrc";
constexpr auto componentId = "se.ajpanton.plasma-task-group-shortcuts"; constexpr auto componentId = "se.ajpanton.plasma-task-group-shortcuts";
constexpr auto stateFile = "plasma-task-group-shortcutsrc"; 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) KConfigGroup displacedShortcutGroup(int number)
{ {
const auto config = KSharedConfig::openConfig(QString::fromLatin1(stateFile)); const auto config = KSharedConfig::openConfig(QString::fromLatin1(stateFile));
@@ -282,15 +299,20 @@ private:
return; return;
} }
const ShortcutSettings settings = shortcutSettings();
const bool continuing = row == m_lastShortcut; const bool continuing = row == m_lastShortcut;
if (!continuing) { 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 { } else {
m_cyclePosition += shifted ? -1 : 1; m_cyclePosition += shifted && settings.shiftCyclesBackward ? -1 : 1;
} }
m_lastShortcut = row; m_lastShortcut = row;
if (shifted && !continuing) { if (shifted && !continuing && settings.initialShiftOpensNewInstance) {
m_model.requestNewInstance(m_model.index(tasks.at(row), 0)); m_model.requestNewInstance(m_model.index(tasks.at(row), 0));
return; return;
} }
@@ -1,7 +1,7 @@
Name: plasma-task-group-shortcuts Name: plasma-task-group-shortcuts
Version: 0.1.0 Version: 0.1.0
Release: 13%{?dist} Release: 15%{?dist}
Summary: Application-group shortcuts for the Plasma Task Manager Summary: Windows-like application-group shortcuts for the Plasma Task Manager
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools 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 %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-13
- Advertise the tool to Fedora Tools settings - Advertise the tool to Fedora Tools settings
@@ -72,3 +72,22 @@ QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunc
} }
return rows; 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;
}
@@ -9,3 +9,4 @@ class QAbstractItemModel;
QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition); QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition);
QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers); QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers);
int initialCyclePosition(const QAbstractItemModel &model, int row, bool startWithFirst, bool backwards);
@@ -17,6 +17,9 @@ private Q_SLOTS:
void returnsTopLevelTask(); void returnsTopLevelTask();
void selectsChildByCyclePosition(); void selectsChildByCyclePosition();
void startsWithFirstChild(); void startsWithFirstChild();
void startsWithLastChildWhenReversing();
void startsAfterActiveChild();
void startsBeforeActiveChildWhenReversing();
void keepsSeparateLaunchersAsSlots(); void keepsSeparateLaunchersAsSlots();
void mergesLauncherWithRunningApplication(); void mergesLauncherWithRunningApplication();
}; };
@@ -59,6 +62,45 @@ void TaskSelectionTest::startsWithFirstChild()
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0, model.index(0, 0))); 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() void TaskSelectionTest::keepsSeparateLaunchersAsSlots()
{ {
QStandardItemModel model; QStandardItemModel model;
+6
View File
@@ -14,6 +14,12 @@ install -m 0644 \
"$repo_root/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua" \ "$repo_root/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua" \
"$topdir/SOURCES/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 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 \ rpmbuild \
--define "_topdir $topdir" \ --define "_topdir $topdir" \
+70 -35
View File
@@ -1,18 +1,23 @@
-- SPDX-License-Identifier: MIT -- SPDX-License-Identifier: MIT
-- --
-- Recognize one touchpad contact that has paused before a quick second-finger -- Recognize one established touchpad contact followed by a quick second-finger
-- tap. Hide the tapping contact from libinput and emit a middle click instead. -- tap. Hide the tapping contact from libinput and emit a configured action.
libinput:register({1}) libinput:register({1})
local MINIMUM_STATIONARY_US = 100 * 1000 local settings = TOUCHPAD_HOLD_TAP_SETTINGS or {}
local MAXIMUM_TAP_US = 150 * 1000 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_ANCHOR_MOVEMENT_MM = 1.5
local MAXIMUM_TAPPER_MOVEMENT_MM = 1.0 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 states = {}
local button_devices = {} local output_devices = {}
local button_device = nil local output_device = nil
local tool_usages = { local tool_usages = {
{ usage = evdev.BTN_TOOL_FINGER, count = 1 }, { usage = evdev.BTN_TOOL_FINGER, count = 1 },
@@ -131,18 +136,19 @@ local function restore_candidate(device, state, reason)
"touchpad-hold-tap: restored candidate (" .. reason .. ")") "touchpad-hold-tap: restored candidate (" .. reason .. ")")
end end
local function emit_middle_click() local function emit_action()
if not button_device then if not output_device then
libinput:log_error( 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 return false
end end
button_device:append_frame({ output_device:append_frame({
{ usage = evdev.BTN_MIDDLE, value = 1 }, { usage = OUTPUT_USAGE, value = 1 },
}) })
button_device:append_frame({ output_device:append_frame({
{ usage = evdev.BTN_MIDDLE, value = 0 }, { usage = OUTPUT_USAGE, value = 0 },
}) })
return true return true
end end
@@ -185,8 +191,14 @@ local function handle_tracking_begin(device, state, tracking_id, timestamp)
state.anchor_slot = contact.slot state.anchor_slot = contact.slot
elseif state.mode == "anchor" and previous_count == 1 then elseif state.mode == "anchor" and previous_count == 1 then
local anchor = state.contacts[state.anchor_slot] local anchor = state.contacts[state.anchor_slot]
if anchor and anchor.rest_x and anchor.rest_y and local old_enough = anchor and
timestamp - anchor.stationary_since >= MINIMUM_STATIONARY_US then 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 contact.hidden = true
state.mode = "candidate" state.mode = "candidate"
state.candidate_slot = contact.slot 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 if contact.hidden and state.source_slot == state.candidate_slot then
local anchor = state.contacts[state.anchor_slot] local anchor = state.contacts[state.anchor_slot]
local duration = timestamp - contact.down_time 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.contacts[state.source_slot] = nil
state.candidate_slot = nil state.candidate_slot = nil
@@ -224,11 +238,12 @@ local function handle_tracking_end(device, state, timestamp)
end end
if accepted then if accepted then
if emit_middle_click() then if emit_action() then
libinput:log_debug("touchpad-hold-tap: middle click") libinput:log_debug(
"touchpad-hold-tap: emitted " .. OUTPUT_EVENT_NAME)
end end
else else
libinput:log_debug("touchpad-hold-tap: ignored long tap") libinput:log_debug("touchpad-hold-tap: ignored tap duration")
end end
return false, true return false, true
end end
@@ -243,6 +258,12 @@ local function handle_tracking_end(device, state, timestamp)
if contact_count(state, false) > 0 then if contact_count(state, false) > 0 then
state.mode = "passthrough" state.mode = "passthrough"
end 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 end
reset_if_clear(state) reset_if_clear(state)
return true, false return true, false
@@ -272,7 +293,7 @@ local function handle_mt_axis(device, state, event, timestamp)
remember_axis(contact, event.usage, event.value) remember_axis(contact, event.usage, event.value)
if contact.slot == state.anchor_slot then 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( movement_exceeded_from(
state, contact, state.candidate_anchor_x, state, contact, state.candidate_anchor_x,
state.candidate_anchor_y, MAXIMUM_ANCHOR_MOVEMENT_MM) then state.candidate_anchor_y, MAXIMUM_ANCHOR_MOVEMENT_MM) then
@@ -373,31 +394,45 @@ end
local function device_removed(device) local function device_removed(device)
states[device] = nil states[device] = nil
button_devices[device] = nil output_devices[device] = nil
if button_device == device then if output_device == device then
button_device = nil output_device = nil
for candidate in pairs(button_devices) do for candidate in pairs(output_devices) do
button_device = candidate output_device = candidate
break break
end end
end 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) 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 properties = device:udev_properties()
local usages = device:usages() local usages = device:usages()
-- Clickpads intentionally ignore BTN_MIDDLE from their own event node. -- Clickpads ignore synthesized buttons on their own event node. Keyboard
-- Route synthesized buttons through an existing pointer device instead. -- events likewise belong on a keyboard, so use a suitable existing device.
if (properties.ID_INPUT_MOUSE or properties.ID_INPUT_POINTINGSTICK) and if can_emit_output(properties, usages) then
not properties.ID_INPUT_TOUCHPAD and if not usages[OUTPUT_USAGE] then
usages[evdev.REL_X] and usages[evdev.REL_Y] then device:enable_evdev_usage(OUTPUT_USAGE)
if not usages[evdev.BTN_MIDDLE] then
device:enable_evdev_usage(evdev.BTN_MIDDLE)
end end
button_devices[device] = true output_devices[device] = true
if not button_device then if not output_device then
button_device = device output_device = device
end end
device:connect("device-removed", device_removed) device:connect("device-removed", device_removed)
end end
@@ -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'
@@ -5,6 +5,8 @@ local function usage(event_type, code)
end end
evdev = { evdev = {
BTN_LEFT = usage(1, 272),
BTN_RIGHT = usage(1, 273),
BTN_MIDDLE = usage(1, 274), BTN_MIDDLE = usage(1, 274),
BTN_TOUCH = usage(1, 330), BTN_TOUCH = usage(1, 330),
BTN_TOOL_FINGER = usage(1, 325), BTN_TOOL_FINGER = usage(1, 325),
@@ -20,8 +22,19 @@ evdev = {
ABS_MT_POSITION_Y = usage(3, 54), ABS_MT_POSITION_Y = usage(3, 54),
ABS_MT_TOOL_Y = usage(3, 61), ABS_MT_TOOL_Y = usage(3, 61),
ABS_MT_TRACKING_ID = usage(3, 57), 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 = {} local plugin_callbacks = {}
libinput = { libinput = {
register = function(_, versions) register = function(_, versions)
@@ -66,6 +79,8 @@ local function new_device(device_type)
return { ID_INPUT_TOUCHPAD = true } return { ID_INPUT_TOUCHPAD = true }
elseif device_type == "mouse" then elseif device_type == "mouse" then
return { ID_INPUT_MOUSE = true } return { ID_INPUT_MOUSE = true }
elseif device_type == "keyboard" then
return { ID_INPUT_KEYBOARD = true }
end end
return {} return {}
end end
@@ -159,12 +174,13 @@ local function up(device, timestamp, slot, count_after)
return feed(device, timestamp, frame) return feed(device, timestamp, frame)
end 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 local count = 0
for _, frame in ipairs(button_device.appended) do for _, frame in ipairs(output_device.appended) do
if contains(frame, evdev.BTN_MIDDLE, 1) then if contains(frame, expected_output, 1) then
count = count + 1 count = count + 1
end end
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)) assert(not contains(second_down, evdev.BTN_TOOL_DOUBLETAP, 1))
local second_up = up(device, 260000, 1, 1) local second_up = up(device, 260000, 1, 1)
assert(not contains(second_up, evdev.ABS_MT_TRACKING_ID, -1)) assert(not contains(second_up, evdev.ABS_MT_TRACKING_ID, -1))
assert(click_count() == 1) assert(action_count() == 1)
assert(#device.appended == 0) assert(#device.appended == 0)
up(device, 440000, 0, 0) up(device, 440000, 0, 0)
end end
@@ -193,7 +209,7 @@ function tests.hold_doubletap_emits_two_middle_clicks()
up(device, 250000, 1, 1) up(device, 250000, 1, 1)
down(device, 360000, 1, 22, 1400, 1000, 2) down(device, 360000, 1, 22, 1400, 1000, 2)
up(device, 420000, 1, 1) up(device, 420000, 1, 1)
assert(click_count() == 2) assert(action_count() == 2)
up(device, 600000, 0, 0) up(device, 600000, 0, 0)
end end
@@ -205,7 +221,7 @@ function tests.ordinary_two_finger_tap_passes_through()
assert(contains(second, evdev.BTN_TOOL_DOUBLETAP, 1)) assert(contains(second, evdev.BTN_TOOL_DOUBLETAP, 1))
up(device, 70000, 1, 1) up(device, 70000, 1, 1)
up(device, 80000, 0, 0) up(device, 80000, 0, 0)
assert(click_count() == 0) assert(action_count() == 0)
end end
function tests.long_stationary_tap_is_consumed_without_click() 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) down(device, 200000, 1, 41, 1400, 1000, 2)
local second_up = up(device, 360001, 1, 1) local second_up = up(device, 360001, 1, 1)
assert(not contains(second_up, evdev.ABS_MT_TRACKING_ID, -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) up(device, 500000, 0, 0)
end 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() function tests.tapper_movement_restores_normal_input()
local device = new_device("touchpad") local device = new_device("touchpad")
down(device, 0, 0, 50, 1000, 1000, 1) 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)) assert(not contains(moved, evdev.ABS_MT_POSITION_X, 1430))
up(device, 260000, 1, 1) up(device, 260000, 1, 1)
up(device, 400000, 0, 0) up(device, 400000, 0, 0)
assert(click_count() == 0) assert(action_count() == 0)
end end
function tests.anchor_movement_prevents_recognition() function tests.anchor_movement_prevents_recognition()
@@ -239,10 +264,11 @@ function tests.anchor_movement_prevents_recognition()
down(device, 0, 0, 60, 1000, 1000, 1) down(device, 0, 0, 60, 1000, 1000, 1)
feed(device, 150000, { event(evdev.ABS_MT_POSITION_X, 1040) }) feed(device, 150000, { event(evdev.ABS_MT_POSITION_X, 1040) })
local second = down(device, 200000, 1, 61, 1400, 1000, 2) 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, 250000, 1, 1)
up(device, 400000, 0, 0) up(device, 400000, 0, 0)
assert(click_count() == 0) assert(action_count() == (allow_moving_anchor and 1 or 0))
end end
function tests.anchor_movement_during_tap_restores_normal_input() 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_SLOT, 0),
event(evdev.ABS_MT_POSITION_X, 1040), event(evdev.ABS_MT_POSITION_X, 1040),
}) })
assert(#device.prepended == 1) assert(#device.prepended == (allow_moving_anchor and 0 or 1))
assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 63)) 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)) assert(contains(moved, evdev.ABS_MT_POSITION_X, 1040))
up(device, 260000, 1, 1) up(device, 260000, 1, 1)
up(device, 400000, 0, 0) 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 end
function tests.anchor_can_move_pause_and_tap_repeatedly() 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) down(device, 650000, 1, 66, 1500, 1100, 2)
up(device, 700000, 1, 1) up(device, 700000, 1, 1)
assert(click_count() == 1) assert(action_count() == 1)
feed(device, 800000, { feed(device, 800000, {
event(evdev.ABS_MT_SLOT, 0), 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) down(device, 950000, 1, 67, 2000, 1200, 2)
up(device, 1000000, 1, 1) up(device, 1000000, 1, 1)
assert(click_count() == 2) assert(action_count() == 2)
up(device, 1100000, 0, 0) up(device, 1100000, 0, 0)
end end
@@ -297,7 +365,7 @@ function tests.third_contact_restores_candidate()
up(device, 260000, 2, 2) up(device, 260000, 2, 2)
up(device, 280000, 1, 1) up(device, 280000, 1, 1)
up(device, 400000, 0, 0) up(device, 400000, 0, 0)
assert(click_count() == 0) assert(action_count() == 0)
end end
function tests.two_finger_hold_tap_remains_passthrough() 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, 360000, 2, 2)
up(device, 500000, 1, 1) up(device, 500000, 1, 1)
up(device, 510000, 0, 0) up(device, 510000, 0, 0)
assert(click_count() == 0) assert(action_count() == 0)
assert(#device.prepended == 0) assert(#device.prepended == 0)
end end
@@ -320,8 +388,8 @@ function tests.non_touchpad_is_ignored()
end end
function tests.pointer_device_is_used_for_button_output() function tests.pointer_device_is_used_for_button_output()
assert(button_device.enabled[evdev.BTN_MIDDLE]) assert(output_device.enabled[expected_output])
assert(button_device.callbacks["evdev-frame"] == nil) assert(output_device.callbacks["evdev-frame"] == nil)
end end
local names = {} local names = {}
@@ -330,7 +398,7 @@ for name in pairs(tests) do
end end
table.sort(names) table.sort(names)
for _, name in ipairs(names) do for _, name in ipairs(names) do
button_device.appended = {} output_device.appended = {}
tests[name]() tests[name]()
print("ok - " .. name) print("ok - " .. name)
end end
+192
View File
@@ -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
+28 -1
View File
@@ -1,6 +1,6 @@
Name: touchpad-hold-tap Name: touchpad-hold-tap
Version: 0.1.0 Version: 0.1.0
Release: 6%{?dist} Release: 9%{?dist}
Summary: Hold-tap middle-click gesture for touchpads Summary: Hold-tap middle-click gesture for touchpads
License: MIT License: MIT
@@ -9,6 +9,8 @@ Source0: 90-touchpad-hold-tap.lua
Source1: LICENSE Source1: LICENSE
Source2: test-touchpad-hold-tap.lua Source2: test-touchpad-hold-tap.lua
Source3: README.md Source3: README.md
Source4: touchpad-hold-tap-config
Source5: test-touchpad-hold-tap-config
BuildRequires: lua BuildRequires: lua
Requires: libinput >= 1.30 Requires: libinput >= 1.30
@@ -25,10 +27,15 @@ gestures. Other touchpad input continues through libinput normally.
%check %check
lua %{SOURCE2} %{SOURCE0} lua %{SOURCE2} %{SOURCE0}
lua %{SOURCE2} %{SOURCE0} KEY_F13
lua %{SOURCE2} %{SOURCE0} BTN_MIDDLE allow-moving-anchor
bash %{SOURCE5} %{SOURCE0} %{SOURCE4}
%install %install
install -Dpm 0644 %{SOURCE0} \ install -Dpm 0644 %{SOURCE0} \
%{buildroot}%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua %{buildroot}%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua
install -Dpm 0755 %{SOURCE4} \
%{buildroot}%{_bindir}/touchpad-hold-tap-config
install -Dpm 0644 %{SOURCE1} \ install -Dpm 0644 %{SOURCE1} \
%{buildroot}%{_licensedir}/%{name}/LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dpm 0644 %{SOURCE3} \ install -Dpm 0644 %{SOURCE3} \
@@ -37,9 +44,29 @@ install -Dpm 0644 %{SOURCE3} \
%files %files
%license %{_licensedir}/%{name}/LICENSE %license %{_licensedir}/%{name}/LICENSE
%doc %{_docdir}/%{name}/README.md %doc %{_docdir}/%{name}/README.md
%{_bindir}/touchpad-hold-tap-config
%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua %{_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 %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-6
- Advertise the tool to Fedora Tools settings - Advertise the tool to Fedora Tools settings