Files
fedora-tools/touchpad-hold-tap/90-touchpad-hold-tap.lua
T

492 lines
16 KiB
Lua

-- SPDX-License-Identifier: MIT
--
-- Recognize one established touchpad contact followed by a quick second-finger
-- tap. Hide the tapping contact from libinput and emit a configured action.
libinput:register({1})
local settings = TOUCHPAD_HOLD_TAP_SETTINGS or {}
local MINIMUM_ANCHOR_AGE_US = (settings.minimum_anchor_age_ms or 100) * 1000
local MINIMUM_STATIONARY_US = (settings.minimum_pause_ms or 100) * 1000
local MINIMUM_TAP_US = (settings.minimum_tap_ms or 10) * 1000
local MAXIMUM_TAP_US = (settings.maximum_tap_ms or 150) * 1000
local MAXIMUM_ANCHOR_MOVEMENT_MM = 1.5
local MAXIMUM_TAPPER_MOVEMENT_MM = 1.0
local OUTPUT_EVENT_NAME = settings.output_event or "BTN_MIDDLE"
local OUTPUT_USAGE = evdev[OUTPUT_EVENT_NAME]
local states = {}
local output_devices = {}
local output_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 dx * dx + dy * dy > limit_mm * 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_action()
if not output_device then
libinput:log_error(
"touchpad-hold-tap: no device available for " ..
OUTPUT_EVENT_NAME .. " output")
return false
end
output_device:append_frame({
{ usage = OUTPUT_USAGE, value = 1 },
})
output_device:append_frame({
{ usage = OUTPUT_USAGE, value = 0 },
})
return true
end
local function reset_contacts(state)
state.mode = "idle"
state.contacts = {}
state.anchor_slot = nil
state.candidate_slot = nil
state.candidate_anchor_x = nil
state.candidate_anchor_y = nil
end
local function reset_if_clear(state)
if contact_count(state, false) == 0 then
reset_contacts(state)
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.contacts[state.source_slot] then
libinput:log_debug(
"touchpad-hold-tap: resetting stale contact state")
reset_contacts(state)
end
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]
local old_enough = anchor and
timestamp - anchor.down_time >=
MINIMUM_ANCHOR_AGE_US
local stationary = MINIMUM_STATIONARY_US == 0 or
(anchor and anchor.rest_x and anchor.rest_y and
timestamp - anchor.stationary_since >=
MINIMUM_STATIONARY_US)
if old_enough and stationary then
contact.hidden = true
state.mode = "candidate"
state.candidate_slot = contact.slot
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 >= MINIMUM_TAP_US 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_action() then
libinput:log_debug(
"touchpad-hold-tap: emitted " .. OUTPUT_EVENT_NAME)
end
else
libinput:log_debug("touchpad-hold-tap: ignored tap duration")
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
elseif state.mode == "passthrough" and state.anchor_slot and
state.contacts[state.anchor_slot] and
contact_count(state, false) == 1 then
-- A rejected second contact must not poison the original anchor.
-- It can become eligible again without first being lifted.
state.mode = "anchor"
end
reset_if_clear(state)
return true, false
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 MINIMUM_STATIONARY_US > 0 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 event.usage == evdev.BTN_TOUCH and event.value == 0 then
table.insert(output, event)
if contact_count(state, false) > 0 then
libinput:log_debug(
"touchpad-hold-tap: resetting empty touch state")
reset_contacts(state)
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
output_devices[device] = nil
if output_device == device then
output_device = nil
for candidate in pairs(output_devices) do
output_device = candidate
break
end
end
end
local function can_emit_output(properties, usages)
if OUTPUT_EVENT_NAME:sub(1, 4) == "BTN_" then
return (properties.ID_INPUT_MOUSE or
properties.ID_INPUT_POINTINGSTICK) and
not properties.ID_INPUT_TOUCHPAD and
usages[evdev.REL_X] and usages[evdev.REL_Y]
end
return properties.ID_INPUT_KEYBOARD
end
local function device_new(device)
if not OUTPUT_USAGE then
libinput:log_error(
"touchpad-hold-tap: unknown output event " .. OUTPUT_EVENT_NAME)
return
end
local properties = device:udev_properties()
local usages = device:usages()
-- Clickpads ignore synthesized buttons on their own event node. Keyboard
-- events likewise belong on a keyboard, so use a suitable existing device.
if can_emit_output(properties, usages) then
if not usages[OUTPUT_USAGE] then
device:enable_evdev_usage(OUTPUT_USAGE)
end
output_devices[device] = true
if not output_device then
output_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)