-- SPDX-License-Identifier: MIT local function usage(event_type, code) return event_type * 65536 + code end evdev = { BTN_LEFT = usage(1, 272), BTN_RIGHT = usage(1, 273), 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), KEY_F13 = usage(1, 183), } local output_event = arg[2] or "BTN_MIDDLE" local allow_moving_anchor = arg[3] == "allow-moving-anchor" local expected_output = evdev[output_event] if arg[2] or allow_moving_anchor then TOUCHPAD_HOLD_TAP_SETTINGS = { output_event = output_event, minimum_pause_ms = allow_moving_anchor and 0 or nil, } end 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 } elseif device_type == "keyboard" then return { ID_INPUT_KEYBOARD = 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 output_device = new_device(output_event:sub(1, 4) == "KEY_" and "keyboard" or "mouse") local function action_count() local count = 0 for _, frame in ipairs(output_device.appended) do if contains(frame, expected_output, 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(action_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(action_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(action_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(action_count() == 0) up(device, 500000, 0, 0) end function tests.implausibly_short_contact_is_consumed_without_click() local device = new_device("touchpad") down(device, 0, 0, 45, 1000, 1000, 1) down(device, 200000, 1, 46, 1400, 1000, 2) up(device, 201000, 1, 1) assert(action_count() == 0) up(device, 400000, 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(action_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) ~= allow_moving_anchor) up(device, 250000, 1, 1) up(device, 400000, 0, 0) assert(action_count() == (allow_moving_anchor and 1 or 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 == (allow_moving_anchor and 0 or 1)) if not allow_moving_anchor then assert(contains(device.prepended[1], evdev.ABS_MT_TRACKING_ID, 63)) end assert(contains(moved, evdev.ABS_MT_POSITION_X, 1040)) up(device, 260000, 1, 1) up(device, 400000, 0, 0) assert(action_count() == (allow_moving_anchor and 1 or 0)) end function tests.rejected_tap_does_not_poison_anchor() local device = new_device("touchpad") down(device, 0, 0, 64, 1000, 1000, 1) -- This near-simultaneous contact is passed through as an ordinary -- two-finger interaction. local early = down(device, 10000, 1, 65, 1400, 1000, 2) assert(contains(early, evdev.ABS_MT_TRACKING_ID, 65)) up(device, 60000, 1, 1) -- The same anchor can recognize a later tap without being lifted first. local later = down(device, 200000, 1, 66, 1400, 1000, 2) assert(not contains(later, evdev.ABS_MT_TRACKING_ID, 66)) up(device, 250000, 1, 1) assert(action_count() == 1) up(device, 300000, 0, 0) end function tests.tap_while_moving_does_not_poison_anchor() if allow_moving_anchor then return end local device = new_device("touchpad") down(device, 0, 0, 67, 1000, 1000, 1) feed(device, 150000, { event(evdev.ABS_MT_SLOT, 0), event(evdev.ABS_MT_POSITION_X, 1040), }) local moving_tap = down(device, 160000, 1, 68, 1400, 1000, 2) assert(contains(moving_tap, evdev.ABS_MT_TRACKING_ID, 68)) up(device, 210000, 1, 1) local later = down(device, 320000, 1, 69, 1400, 1000, 2) assert(not contains(later, evdev.ABS_MT_TRACKING_ID, 69)) up(device, 370000, 1, 1) assert(action_count() == 1) up(device, 400000, 0, 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(action_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(action_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(action_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(action_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(output_device.enabled[expected_output]) assert(output_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 output_device.appended = {} tests[name]() print("ok - " .. name) end print(string.format("%d tests passed", #names))