-- 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 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_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_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] 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 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)