Add touchpad hold-tap tool

This commit is contained in:
ajp_anton
2026-08-30 00:28:02 +00:00
commit 1f0090392d
9 changed files with 963 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# RPM build output is published to Gitea's package registry, not Git.
*.rpm
rpmbuild/
# Machine-local captures, experiments, and other unpublished files.
local/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fedora-tools contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
# Fedora tools
Small utilities for Fedora Linux, distributed as RPM packages through a Gitea
package registry. Installed tools receive updates through DNF and compatible
graphical software managers.
These tools are developed with the help of AI.
## Repository
```bash
sudo dnf config-manager addrepo \
--from-repofile=https://git.ajpanton.se/ajp_anton/fedora-tools/raw/branch/main/fedora-tools.repo
```
```bash
sudo dnf install touchpad-hold-tap
```
## Compatibility
The primary test environment is a Framework Laptop 13 Pro running Fedora Linux
44 on x86_64, with KDE Plasma 6.7 and a Wayland session using KWin.
Compatibility with other Fedora releases, desktop environments, display
systems, architectures, or distributions may be limited.
## Tools
### Touchpad hold-tap
Experimental. Recognizes a one-finger hold followed by a quick tap from a
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 `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.
+6
View File
@@ -0,0 +1,6 @@
[fedora-tools]
name=Small tools for Fedora
baseurl=https://git.ajpanton.se/api/packages/ajp_anton/rpm/fedora/$releasever
enabled=1
gpgcheck=1
gpgkey=https://git.ajpanton.se/api/packages/ajp_anton/rpm/repository.key
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
topdir="$repo_root/rpmbuild"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
install -m 0644 \
"$repo_root/touchpad-hold-tap/90-touchpad-hold-tap.lua" \
"$topdir/SOURCES/90-touchpad-hold-tap.lua"
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
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"
rpmbuild \
--define "_topdir $topdir" \
-bb "$repo_root/touchpad-hold-tap/touchpad-hold-tap.spec"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'Usage: GITEA_TOKEN=... %s PATH_TO_RPM FEDORA_RELEASE\n' "$0" >&2
}
if (( $# != 2 )); then
usage
exit 2
fi
rpm_path=$1
fedora_release=$2
if [[ ! -f $rpm_path || $rpm_path != *.rpm ]]; then
printf 'Error: %s is not an RPM file.\n' "$rpm_path" >&2
exit 2
fi
if [[ ! $fedora_release =~ ^[0-9]+$ ]]; then
printf 'Error: Fedora release must be a number, such as 44.\n' >&2
exit 2
fi
if [[ -z ${GITEA_TOKEN:-} ]]; then
printf 'Error: set GITEA_TOKEN to a Gitea access token with package write permission.\n' >&2
exit 2
fi
upload_url="https://git.ajpanton.se/api/packages/ajp_anton/rpm/fedora/${fedora_release}/upload"
curl \
--fail-with-body \
--show-error \
--silent \
--user "ajp_anton:${GITEA_TOKEN}" \
--upload-file "$rpm_path" \
"$upload_url"
printf 'Published %s for Fedora %s.\n' "$rpm_path" "$fedora_release"
+438
View File
@@ -0,0 +1,438 @@
-- 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.
libinput:register({1})
local MINIMUM_STATIONARY_US = 100 * 1000
local MAXIMUM_TAP_US = 150 * 1000
local MAXIMUM_ANCHOR_MOVEMENT_MM = 1.5
local MAXIMUM_TAPPER_MOVEMENT_MM = 1.0
local states = {}
local button_devices = {}
local button_device = nil
local tool_usages = {
{ usage = evdev.BTN_TOOL_FINGER, count = 1 },
{ usage = evdev.BTN_TOOL_DOUBLETAP, count = 2 },
{ usage = evdev.BTN_TOOL_TRIPLETAP, count = 3 },
{ usage = evdev.BTN_TOOL_QUADTAP, count = 4 },
{ usage = evdev.BTN_TOOL_QUINTTAP, count = 5 },
}
local tool_counts = {}
for _, item in ipairs(tool_usages) do
tool_counts[item.usage] = item.count
end
local function contact_count(state, visible_only)
local count = 0
for _, contact in pairs(state.contacts) do
if not visible_only or not contact.hidden then
count = count + 1
end
end
return count
end
local function axis_distance(delta, absinfo)
if absinfo and absinfo.resolution and absinfo.resolution > 0 then
return math.abs(delta) / absinfo.resolution
end
if absinfo and absinfo.maximum and absinfo.minimum then
local range = absinfo.maximum - absinfo.minimum
if range > 0 then
-- A normalized fallback for devices without a physical resolution.
return math.abs(delta) * 100 / range
end
end
if delta == 0 then
return 0
end
return math.huge
end
local function movement_exceeded_from(state, contact, origin_x, origin_y,
limit_mm)
if not origin_x or not origin_y or
not contact.x or not contact.y then
return false
end
local dx = axis_distance(contact.x - origin_x, state.x_absinfo)
local dy = axis_distance(contact.y - origin_y, state.y_absinfo)
return math.sqrt(dx * dx + dy * dy) > limit_mm
end
local function movement_exceeded(state, contact, limit_mm)
return movement_exceeded_from(
state, contact, contact.start_x, contact.start_y, limit_mm)
end
local function remember_axis(contact, usage, value)
if contact.axes[usage] == nil then
table.insert(contact.axis_order, usage)
end
contact.axes[usage] = value
if usage == evdev.ABS_MT_POSITION_X then
contact.x = value
if contact.start_x == nil then
contact.start_x = value
end
elseif usage == evdev.ABS_MT_POSITION_Y then
contact.y = value
if contact.start_y == nil then
contact.start_y = value
end
end
end
local function update_output_tools(state, frame, count)
for _, item in ipairs(tool_usages) do
if state.usages[item.usage] then
local desired = item.count == count and 1 or 0
if state.output_tools[item.usage] ~= desired then
table.insert(frame, { usage = item.usage, value = desired })
state.output_tools[item.usage] = desired
end
end
end
end
local function restore_candidate(device, state, reason)
local contact = state.contacts[state.candidate_slot]
if not contact or not contact.hidden then
return
end
contact.hidden = false
local frame = {
{ usage = evdev.ABS_MT_SLOT, value = contact.slot },
{ usage = evdev.ABS_MT_TRACKING_ID, value = contact.tracking_id },
}
for _, usage in ipairs(contact.axis_order) do
table.insert(frame, { usage = usage, value = contact.axes[usage] })
end
update_output_tools(state, frame, contact_count(state, true))
device:prepend_frame(frame)
state.output_slot = contact.slot
state.candidate_slot = nil
state.candidate_anchor_x = nil
state.candidate_anchor_y = nil
state.mode = "passthrough"
libinput:log_debug(
"touchpad-hold-tap: restored candidate (" .. reason .. ")")
end
local function emit_middle_click()
if not button_device then
libinput:log_error(
"touchpad-hold-tap: no pointer device available for button output")
return false
end
button_device:append_frame({
{ usage = evdev.BTN_MIDDLE, value = 1 },
})
button_device:append_frame({
{ usage = evdev.BTN_MIDDLE, value = 0 },
})
return true
end
local function reset_if_clear(state)
if contact_count(state, false) == 0 then
state.mode = "idle"
state.anchor_slot = nil
state.candidate_slot = nil
state.candidate_anchor_x = nil
state.candidate_anchor_y = nil
end
end
local function new_contact(state, slot, tracking_id, timestamp)
local contact = {
slot = slot,
tracking_id = tracking_id,
down_time = timestamp,
hidden = false,
stationary_since = timestamp,
axes = {},
axis_order = {},
}
state.contacts[slot] = contact
return contact
end
local function handle_tracking_begin(device, state, tracking_id, timestamp)
if state.mode == "candidate" then
restore_candidate(device, state, "extra contact")
end
local previous_count = contact_count(state, false)
local contact = new_contact(
state, state.source_slot, tracking_id, timestamp)
if state.mode == "idle" and previous_count == 0 then
state.mode = "anchor"
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
contact.hidden = true
state.mode = "candidate"
state.candidate_slot = contact.slot
state.candidate_anchor_x = anchor.x
state.candidate_anchor_y = anchor.y
else
state.mode = "passthrough"
end
else
state.mode = "passthrough"
end
return not contact.hidden
end
local function handle_tracking_end(device, state, timestamp)
local contact = state.contacts[state.source_slot]
if not contact then
return true, false
end
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
state.contacts[state.source_slot] = nil
state.candidate_slot = nil
state.candidate_anchor_x = nil
state.candidate_anchor_y = nil
if anchor then
state.mode = "anchor"
else
reset_if_clear(state)
end
if accepted then
if emit_middle_click() then
libinput:log_debug("touchpad-hold-tap: middle click")
end
else
libinput:log_debug("touchpad-hold-tap: ignored long tap")
end
return false, true
end
if state.mode == "candidate" and state.source_slot == state.anchor_slot then
restore_candidate(device, state, "anchor lifted first")
end
state.contacts[state.source_slot] = nil
if state.source_slot == state.anchor_slot then
state.anchor_slot = nil
if contact_count(state, false) > 0 then
state.mode = "passthrough"
end
end
reset_if_clear(state)
return true, false
end
local function update_anchor_rest(state, contact, timestamp)
if not contact.rest_x or not contact.rest_y then
if contact.x and contact.y then
contact.rest_x = contact.x
contact.rest_y = contact.y
end
elseif movement_exceeded_from(
state, contact, contact.rest_x, contact.rest_y,
MAXIMUM_ANCHOR_MOVEMENT_MM) then
contact.rest_x = contact.x
contact.rest_y = contact.y
contact.stationary_since = timestamp
end
end
local function handle_mt_axis(device, state, event, timestamp)
local contact = state.contacts[state.source_slot]
if not contact then
return true
end
remember_axis(contact, event.usage, event.value)
if contact.slot == state.anchor_slot then
if state.mode == "candidate" and
movement_exceeded_from(
state, contact, state.candidate_anchor_x,
state.candidate_anchor_y, MAXIMUM_ANCHOR_MOVEMENT_MM) then
restore_candidate(device, state, "anchor movement")
elseif state.mode ~= "candidate" then
update_anchor_rest(state, contact, timestamp)
end
elseif contact.hidden and
movement_exceeded(state, contact, MAXIMUM_TAPPER_MOVEMENT_MM) then
restore_candidate(device, state, "tapper movement")
-- The restored frame already contains this axis value.
return false
end
return not contact.hidden
end
local function emit_mt_event(state, output, event)
if state.output_slot ~= state.source_slot then
table.insert(output, {
usage = evdev.ABS_MT_SLOT,
value = state.source_slot,
})
state.output_slot = state.source_slot
end
table.insert(output, event)
end
local function handle_frame(device, frame, timestamp)
local state = states[device]
local output = {}
local modified = false
local override_tools = false
for _, event in ipairs(frame) do
if event.usage == evdev.ABS_MT_SLOT then
state.source_slot = event.value
local contact = state.contacts[event.value]
if contact and contact.hidden then
modified = true
end
elseif event.usage == evdev.ABS_MT_TRACKING_ID then
local visible
local hidden_ended = false
if event.value >= 0 then
visible = handle_tracking_begin(
device, state, event.value, timestamp)
else
visible, hidden_ended = handle_tracking_end(
device, state, timestamp)
end
if visible then
emit_mt_event(state, output, event)
else
modified = true
end
if hidden_ended then
override_tools = true
end
elseif event.usage >= evdev.ABS_MT_TOUCH_MAJOR and
event.usage <= evdev.ABS_MT_TOOL_Y then
if handle_mt_axis(device, state, event, timestamp) then
emit_mt_event(state, output, event)
else
modified = true
end
elseif tool_counts[event.usage] then
if state.mode == "candidate" or override_tools then
local desired = tool_counts[event.usage] ==
contact_count(state, true) and 1 or 0
if state.output_tools[event.usage] ~= desired then
table.insert(output, {
usage = event.usage,
value = desired,
})
state.output_tools[event.usage] = desired
end
modified = true
else
table.insert(output, event)
if event.value == 0 or event.value == 1 then
state.output_tools[event.usage] = event.value
end
end
else
table.insert(output, event)
end
end
if modified then
return output
end
state.output_slot = state.source_slot
return nil
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
break
end
end
end
local function device_new(device)
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)
end
button_devices[device] = true
if not button_device then
button_device = device
end
device:connect("device-removed", device_removed)
end
if not properties.ID_INPUT_TOUCHPAD or
not usages[evdev.ABS_MT_SLOT] or
not usages[evdev.ABS_MT_TRACKING_ID] or
not usages[evdev.ABS_MT_POSITION_X] or
not usages[evdev.ABS_MT_POSITION_Y] then
return
end
local absinfos = device:absinfos()
local output_tools = {}
for _, item in ipairs(tool_usages) do
output_tools[item.usage] = 0
end
states[device] = {
mode = "idle",
contacts = {},
anchor_slot = nil,
candidate_slot = nil,
candidate_anchor_x = nil,
candidate_anchor_y = nil,
source_slot = 0,
output_slot = 0,
usages = usages,
output_tools = output_tools,
x_absinfo = absinfos[evdev.ABS_MT_POSITION_X],
y_absinfo = absinfos[evdev.ABS_MT_POSITION_Y],
}
device:connect("evdev-frame", handle_frame)
device:connect("device-removed", device_removed)
end
libinput:connect("new-evdev-device", device_new)
@@ -0,0 +1,337 @@
-- SPDX-License-Identifier: MIT
local function usage(event_type, code)
return event_type * 65536 + code
end
evdev = {
BTN_MIDDLE = usage(1, 274),
BTN_TOUCH = usage(1, 330),
BTN_TOOL_FINGER = usage(1, 325),
BTN_TOOL_QUINTTAP = usage(1, 328),
BTN_TOOL_DOUBLETAP = usage(1, 333),
BTN_TOOL_TRIPLETAP = usage(1, 334),
BTN_TOOL_QUADTAP = usage(1, 335),
REL_X = usage(2, 0),
REL_Y = usage(2, 1),
ABS_MT_SLOT = usage(3, 47),
ABS_MT_TOUCH_MAJOR = usage(3, 48),
ABS_MT_POSITION_X = usage(3, 53),
ABS_MT_POSITION_Y = usage(3, 54),
ABS_MT_TOOL_Y = usage(3, 61),
ABS_MT_TRACKING_ID = usage(3, 57),
}
local plugin_callbacks = {}
libinput = {
register = function(_, versions)
assert(versions[1] == 1)
return 1
end,
connect = function(_, name, callback)
plugin_callbacks[name] = callback
end,
log_debug = function() end,
log_error = function() end,
}
dofile(arg[1])
local function new_device(device_type)
local callbacks = {}
local device = {
callbacks = callbacks,
prepended = {},
appended = {},
enabled = {},
}
local usages = {
[evdev.ABS_MT_SLOT] = true,
[evdev.ABS_MT_TRACKING_ID] = true,
[evdev.ABS_MT_POSITION_X] = true,
[evdev.ABS_MT_POSITION_Y] = true,
[evdev.BTN_TOUCH] = true,
[evdev.BTN_TOOL_FINGER] = true,
[evdev.BTN_TOOL_DOUBLETAP] = true,
[evdev.BTN_TOOL_TRIPLETAP] = true,
[evdev.BTN_TOOL_QUADTAP] = true,
[evdev.BTN_TOOL_QUINTTAP] = true,
}
if device_type == "mouse" then
usages[evdev.REL_X] = true
usages[evdev.REL_Y] = true
end
function device:udev_properties()
if device_type == "touchpad" then
return { ID_INPUT_TOUCHPAD = true }
elseif device_type == "mouse" then
return { ID_INPUT_MOUSE = true }
end
return {}
end
function device:usages()
return usages
end
function device:absinfos()
return {
[evdev.ABS_MT_POSITION_X] = {
minimum = 0, maximum = 2833, resolution = 24,
},
[evdev.ABS_MT_POSITION_Y] = {
minimum = 0, maximum = 1723, resolution = 24,
},
}
end
function device:enable_evdev_usage(code)
self.enabled[code] = true
usages[code] = true
end
function device:connect(name, callback)
callbacks[name] = callback
end
function device:prepend_frame(frame)
table.insert(self.prepended, frame)
end
function device:append_frame(frame)
table.insert(self.appended, frame)
end
plugin_callbacks["new-evdev-device"](device)
return device
end
local function event(code, value)
return { usage = code, value = value }
end
local function contains(frame, code, value)
if not frame then
return false
end
for _, item in ipairs(frame) do
if item.usage == code and (value == nil or item.value == value) then
return true
end
end
return false
end
local function feed(device, timestamp, frame)
local output = device.callbacks["evdev-frame"](device, frame, timestamp)
if output == nil then
return frame
end
return output
end
local function down(device, timestamp, slot, tracking_id, x, y, count)
local frame = {
event(evdev.ABS_MT_SLOT, slot),
event(evdev.ABS_MT_TRACKING_ID, tracking_id),
event(evdev.ABS_MT_POSITION_X, x),
event(evdev.ABS_MT_POSITION_Y, y),
}
if count == 1 then
table.insert(frame, event(evdev.BTN_TOUCH, 1))
table.insert(frame, event(evdev.BTN_TOOL_FINGER, 1))
elseif count == 2 then
table.insert(frame, event(evdev.BTN_TOOL_FINGER, 0))
table.insert(frame, event(evdev.BTN_TOOL_DOUBLETAP, 1))
elseif count == 3 then
table.insert(frame, event(evdev.BTN_TOOL_DOUBLETAP, 0))
table.insert(frame, event(evdev.BTN_TOOL_TRIPLETAP, 1))
end
return feed(device, timestamp, frame)
end
local function up(device, timestamp, slot, count_after)
local frame = {
event(evdev.ABS_MT_SLOT, slot),
event(evdev.ABS_MT_TRACKING_ID, -1),
}
if count_after == 1 then
table.insert(frame, event(evdev.BTN_TOOL_FINGER, 1))
table.insert(frame, event(evdev.BTN_TOOL_DOUBLETAP, 0))
elseif count_after == 0 then
table.insert(frame, event(evdev.BTN_TOUCH, 0))
table.insert(frame, event(evdev.BTN_TOOL_FINGER, 0))
end
return feed(device, timestamp, frame)
end
local button_device = new_device("mouse")
local function click_count()
local count = 0
for _, frame in ipairs(button_device.appended) do
if contains(frame, evdev.BTN_MIDDLE, 1) then
count = count + 1
end
end
return count
end
local tests = {}
function tests.hold_tap_emits_middle_and_hides_tapper()
local device = new_device("touchpad")
down(device, 0, 0, 10, 1000, 1000, 1)
local second_down = down(device, 200000, 1, 11, 1400, 1000, 2)
assert(not contains(second_down, evdev.ABS_MT_TRACKING_ID, 11))
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(#device.appended == 0)
up(device, 440000, 0, 0)
end
function tests.hold_doubletap_emits_two_middle_clicks()
local device = new_device("touchpad")
down(device, 0, 0, 20, 1000, 1000, 1)
down(device, 200000, 1, 21, 1400, 1000, 2)
up(device, 250000, 1, 1)
down(device, 360000, 1, 22, 1400, 1000, 2)
up(device, 420000, 1, 1)
assert(click_count() == 2)
up(device, 600000, 0, 0)
end
function tests.ordinary_two_finger_tap_passes_through()
local device = new_device("touchpad")
down(device, 0, 0, 30, 1000, 1000, 1)
local second = down(device, 10000, 1, 31, 1400, 1000, 2)
assert(contains(second, evdev.ABS_MT_TRACKING_ID, 31))
assert(contains(second, evdev.BTN_TOOL_DOUBLETAP, 1))
up(device, 70000, 1, 1)
up(device, 80000, 0, 0)
assert(click_count() == 0)
end
function tests.long_stationary_tap_is_consumed_without_click()
local device = new_device("touchpad")
down(device, 0, 0, 40, 1000, 1000, 1)
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)
up(device, 500000, 0, 0)
end
function tests.tapper_movement_restores_normal_input()
local device = new_device("touchpad")
down(device, 0, 0, 50, 1000, 1000, 1)
down(device, 200000, 1, 51, 1400, 1000, 2)
local moved = feed(device, 230000, {
event(evdev.ABS_MT_POSITION_X, 1430),
})
assert(#device.prepended == 1)
assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 51))
assert(contains(device.prepended[1], evdev.ABS_MT_POSITION_X, 1430))
assert(not contains(moved, evdev.ABS_MT_POSITION_X, 1430))
up(device, 260000, 1, 1)
up(device, 400000, 0, 0)
assert(click_count() == 0)
end
function tests.anchor_movement_prevents_recognition()
local device = new_device("touchpad")
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))
up(device, 250000, 1, 1)
up(device, 400000, 0, 0)
assert(click_count() == 0)
end
function tests.anchor_movement_during_tap_restores_normal_input()
local device = new_device("touchpad")
down(device, 0, 0, 62, 1000, 1000, 1)
down(device, 200000, 1, 63, 1400, 1000, 2)
local moved = feed(device, 230000, {
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(contains(moved, evdev.ABS_MT_POSITION_X, 1040))
up(device, 260000, 1, 1)
up(device, 400000, 0, 0)
assert(click_count() == 0)
end
function tests.anchor_can_move_pause_and_tap_repeatedly()
local device = new_device("touchpad")
down(device, 0, 0, 65, 1000, 1000, 1)
feed(device, 500000, {
event(evdev.ABS_MT_SLOT, 0),
event(evdev.ABS_MT_POSITION_X, 1300),
event(evdev.ABS_MT_POSITION_Y, 1100),
})
down(device, 650000, 1, 66, 1500, 1100, 2)
up(device, 700000, 1, 1)
assert(click_count() == 1)
feed(device, 800000, {
event(evdev.ABS_MT_SLOT, 0),
event(evdev.ABS_MT_POSITION_X, 1800),
event(evdev.ABS_MT_POSITION_Y, 1200),
})
down(device, 950000, 1, 67, 2000, 1200, 2)
up(device, 1000000, 1, 1)
assert(click_count() == 2)
up(device, 1100000, 0, 0)
end
function tests.third_contact_restores_candidate()
local device = new_device("touchpad")
down(device, 0, 0, 70, 1000, 1000, 1)
down(device, 200000, 1, 71, 1400, 1000, 2)
local third = down(device, 230000, 2, 72, 1800, 1000, 3)
assert(#device.prepended == 1)
assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 71))
assert(contains(third, evdev.ABS_MT_TRACKING_ID, 72))
up(device, 260000, 2, 2)
up(device, 280000, 1, 1)
up(device, 400000, 0, 0)
assert(click_count() == 0)
end
function tests.two_finger_hold_tap_remains_passthrough()
local device = new_device("touchpad")
down(device, 0, 0, 80, 1000, 1000, 1)
down(device, 10000, 1, 81, 1400, 1000, 2)
local third = down(device, 300000, 2, 82, 1800, 1000, 3)
assert(contains(third, evdev.ABS_MT_TRACKING_ID, 82))
up(device, 360000, 2, 2)
up(device, 500000, 1, 1)
up(device, 510000, 0, 0)
assert(click_count() == 0)
assert(#device.prepended == 0)
end
function tests.non_touchpad_is_ignored()
local device = new_device("other")
assert(device.callbacks["evdev-frame"] == nil)
assert(not device.enabled[evdev.BTN_MIDDLE])
end
function tests.pointer_device_is_used_for_button_output()
assert(button_device.enabled[evdev.BTN_MIDDLE])
assert(button_device.callbacks["evdev-frame"] == nil)
end
local names = {}
for name in pairs(tests) do
table.insert(names, name)
end
table.sort(names)
for _, name in ipairs(names) do
button_device.appended = {}
tests[name]()
print("ok - " .. name)
end
print(string.format("%d tests passed", #names))
+55
View File
@@ -0,0 +1,55 @@
Name: touchpad-hold-tap
Version: 0.1.0
Release: 5%{?dist}
Summary: Hold-tap middle-click gesture for touchpads
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
Source0: 90-touchpad-hold-tap.lua
Source1: LICENSE
Source2: test-touchpad-hold-tap.lua
Source3: README.md
BuildRequires: lua
Requires: libinput >= 1.30
%description
A libinput Lua plugin that recognizes a one-finger hold followed by a quick tap
from a second finger and emits a middle click. The held finger may move between
gestures. Other touchpad input continues through libinput normally.
%prep
%build
%check
lua %{SOURCE2} %{SOURCE0}
%install
install -Dpm 0644 %{SOURCE0} \
%{buildroot}%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua
install -Dpm 0644 %{SOURCE1} \
%{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dpm 0644 %{SOURCE3} \
%{buildroot}%{_docdir}/%{name}/README.md
%files
%license %{_licensedir}/%{name}/LICENSE
%doc %{_docdir}/%{name}/README.md
%{_libdir}/libinput/plugins/90-touchpad-hold-tap.lua
%changelog
* Sun Aug 30 2026 fedora-tools contributors - 0.1.0-5
- Run state-machine tests during RPM builds and package the README
* Sun Aug 30 2026 fedora-tools contributors - 0.1.0-4
- Allow the anchor finger to move and become stationary again
* Sun Aug 30 2026 fedora-tools contributors - 0.1.0-3
- Route synthesized clicks through an existing pointer device
* Sun Aug 30 2026 fedora-tools contributors - 0.1.0-2
- Route synthesized clicks through a non-clickpad input device
* Sat Aug 29 2026 fedora-tools contributors - 0.1.0-1
- Initial package