Add touchpad hold-tap tool
This commit is contained in:
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+20
@@ -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"
|
||||||
Executable
+42
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user