neyuki 0.2.0

A small Lua-like scripting language implemented in Rust
Documentation
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
    local fromX, fromY = __hid_position()
    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.
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)