local table = require("@neyuki/table")
local string = require("@neyuki/string")
local math = require("@neyuki/math")
local os = require("@neyuki/os")
local hid = {}
-- Keyboard, mouse, screen and clipboard automation on top of the `__hid_*`
-- natives in src/vm/libs/hid.rs. Input goes to whichever window has focus,
-- exactly as if a person typed or clicked.
--
-- Keys are single characters ("a", "7", "/") or names such as "enter",
-- "ctrl", "shift", "alt", "meta", "tab", "space", "escape", "up", "f5" or
-- "pageup" (case-insensitive). Mouse buttons are "left" (the default),
-- "right", "middle", "back" and "forward". Coordinates are screen pixels
-- from the top-left corner of the main display.
function checkString(s: string, name: string)
if (s == nil) then error(name .. " must be provided") end
if (type(s) != "string") then error(name .. " must be a string") end
end
function checkNumber(n: number, name: string)
if (n == nil) then error(name .. " must be provided") end
if (type(n) != "number") then error(name .. " must be a number") end
end
function checkKey(key: string)
checkString(key, "key")
if (not __hid_is_key(key)) then error("unknown key `" .. key .. "`") end
end
function checkButton(button: string?): string
button = button ?? "left"
checkString(button, "button")
if (not __hid_is_button(button)) then error("unknown mouse button `" .. button .. "`") end
return button
end
function checkCount(count: number?, name: string): number
count = count ?? 1
checkNumber(count, name)
if (count < 0 or count != math.floor(count)) then
error(name .. " must be a non-negative integer")
end
return count
end
-- A list of keys from either separate arguments or one "ctrl+shift+s" string.
function keyList(keys: {string}): {string}
if (#keys == 1 and type(keys[1]) == "string" and #keys[1] > 1) then
local parts = string.split(keys[1], "+")
-- "ctrl++" ends in an empty part for the plus key itself.
if (#parts > 1 and parts[#parts] == "") then parts[#parts] = "+" end
keys = parts
end
if (#keys == 0) then error("at least one key must be given") end
for _, key in keys do checkKey(key) end
return keys
end
local keyboard = {}
-- Holds a key down until `release`.
function keyboard.press(key: string): ()
checkKey(key)
__hid_key(key, "press")
end
function keyboard.release(key: string): ()
checkKey(key)
__hid_key(key, "release")
end
-- Presses and releases a key `times` times (default 1).
function keyboard.tap(key: string, times: number?): ()
checkKey(key)
for _ = 1, checkCount(times, "times") do __hid_key(key, "click") end
end
-- Types text, including characters no key on the layout produces.
function keyboard.write(text: string): ()
checkString(text, "text")
__hid_text(text)
end
-- Presses the keys in order and releases them in reverse:
-- `combo("ctrl", "c")` or `combo("ctrl+c")`.
function keyboard.combo(...: string): ()
local keys = keyList({...})
for _, key in keys do __hid_key(key, "press") end
for index = #keys, 1, -1 do __hid_key(keys[index], "release") end
end
-- Runs `action` while `keys` ("shift" or "ctrl+alt") are held, releasing
-- them even when it fails, and returns what it returned.
function keyboard.hold(keys: string, action: () -> ...any): ...any
local list = keyList({keys})
if (type(action) != "function") then error("action must be a function") end
for _, key in list do __hid_key(key, "press") end
local results = { try(action) }
for index = #list, 1, -1 do __hid_key(list[index], "release") end
if (not results[1]) then error(results[2]) end
return table.unpack(results, 2)
end
-- Whether `key` is a name this library understands.
function keyboard.isKey(key: string): boolean
checkString(key, "key")
return __hid_is_key(key)
end
local mouse = {}
-- Moves the pointer to a screen position.
function mouse.move(x: number, y: number): ()
checkNumber(x, "x")
checkNumber(y, "y")
__hid_move(math.round(x), math.round(y), false)
end
-- Moves the pointer by an offset from where it is.
function mouse.moveBy(dx: number, dy: number): ()
checkNumber(dx, "dx")
checkNumber(dy, "dy")
__hid_move(math.round(dx), math.round(dy), true)
end
-- Moves the pointer to a position in a straight line over `seconds`
-- (default 0.25), for applications that ignore jumps.
function mouse.glide(x: number, y: number, seconds: number?): ()
checkNumber(x, "x")
checkNumber(y, "y")
seconds = seconds ?? 0.25
checkNumber(seconds, "seconds")
if (seconds < 0) then error("seconds must not be negative") end
-- Wayland does not report where the pointer starts until it has been
-- moved, so the first glide there is a jump.
local known, fromX, fromY = try(__hid_position)
if (not known) then
__hid_move(math.round(x), math.round(y), false)
return
end
local steps = math.max(1, math.floor(seconds * 60))
for step = 1, steps do
local t = step / steps
__hid_move(math.round(fromX + (x - fromX) * t), math.round(fromY + (y - fromY) * t), false)
if (step < steps) then os.sleep(seconds / steps) end
end
end
-- The pointer position as two numbers. On Wayland it is where this library
-- last moved the pointer, and an error before the first move.
function mouse.position(): (number, number)
return __hid_position()
end
-- Clicks a button `count` times (default once; 2 is a double click).
function mouse.click(button: string?, count: number?): ()
local resolved = checkButton(button)
for _ = 1, checkCount(count, "count") do __hid_button(resolved, "click") end
end
function mouse.press(button: string?): ()
__hid_button(checkButton(button), "press")
end
function mouse.release(button: string?): ()
__hid_button(checkButton(button), "release")
end
-- Presses a button where the pointer is, glides to a position and releases.
function mouse.drag(x: number, y: number, button: string?, seconds: number?): ()
local resolved = checkButton(button)
__hid_button(resolved, "press")
local ok, err = try(mouse.glide, x, y, seconds)
__hid_button(resolved, "release")
if (not ok) then error(err) end
end
-- Scrolls by wheel steps: positive is down, negative up. With
-- `horizontal`, positive is right.
function mouse.scroll(amount: number, horizontal: boolean?): ()
checkNumber(amount, "amount")
if (horizontal != nil and type(horizontal) != "boolean") then
error("horizontal must be a boolean")
end
__hid_scroll(math.round(amount), horizontal ?? false)
end
local screen = {}
-- The main display's width and height in pixels.
function screen.size(): (number, number)
return __hid_screen_size()
end
local clipboard = {}
-- The clipboard's text, or nil when it holds something else or nothing.
function clipboard.get(): string?
return __hid_clipboard_get()
end
function clipboard.set(text: string): ()
checkString(text, "text")
__hid_clipboard_set(text)
end
hid.keyboard = table.freeze(keyboard)
hid.mouse = table.freeze(mouse)
hid.screen = table.freeze(screen)
hid.clipboard = table.freeze(clipboard)
return table.freeze(hid)