492 lines
16 KiB
Lua
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)
|