commit 1f0090392d5cf2d4b8a61686ceec07c92dae35b9 Author: ajp_anton Date: Sun Aug 30 00:28:02 2026 +0000 Add touchpad hold-tap tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0c9b74 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..529bc45 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec3f9eb --- /dev/null +++ b/README.md @@ -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. diff --git a/fedora-tools.repo b/fedora-tools.repo new file mode 100644 index 0000000..e4c3d29 --- /dev/null +++ b/fedora-tools.repo @@ -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 diff --git a/scripts/build-touchpad-hold-tap-rpm b/scripts/build-touchpad-hold-tap-rpm new file mode 100755 index 0000000..5c777f0 --- /dev/null +++ b/scripts/build-touchpad-hold-tap-rpm @@ -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" diff --git a/scripts/publish-rpm b/scripts/publish-rpm new file mode 100755 index 0000000..876b9e3 --- /dev/null +++ b/scripts/publish-rpm @@ -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" diff --git a/touchpad-hold-tap/90-touchpad-hold-tap.lua b/touchpad-hold-tap/90-touchpad-hold-tap.lua new file mode 100644 index 0000000..091577e --- /dev/null +++ b/touchpad-hold-tap/90-touchpad-hold-tap.lua @@ -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) diff --git a/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua b/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua new file mode 100644 index 0000000..8f0d9a4 --- /dev/null +++ b/touchpad-hold-tap/tests/test-touchpad-hold-tap.lua @@ -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)) diff --git a/touchpad-hold-tap/touchpad-hold-tap.spec b/touchpad-hold-tap/touchpad-hold-tap.spec new file mode 100644 index 0000000..b4fbdbc --- /dev/null +++ b/touchpad-hold-tap/touchpad-hold-tap.spec @@ -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