Keyboard Compass

@rebindv0.1.5

Move, steer, click and drag the pointer from a keyboard layer.

Files

main.luau

local schema = {
  fine = UI.Slider(100, {
    min = 10,
    max = 3000,
    step = 10,
    suffix = " counts/s",
    label = "Fine speed",
    tab = "Movement",
  }),
  normal = UI.Slider(500, {
    min = 10,
    max = 3000,
    step = 10,
    suffix = " counts/s",
    label = "Normal speed",
    tab = "Movement",
  }),
  fast = UI.Slider(1200, {
    min = 10,
    max = 3000,
    step = 10,
    suffix = " counts/s",
    label = "Fast speed",
    tab = "Movement",
  }),
  turn = UI.Slider(90, {
    min = 15,
    max = 360,
    step = 15,
    suffix = " degrees/s",
    label = "Steering speed",
    tab = "Movement",
  }),
}
local CONTROLS = {
  { "toggle", "F8", "Toggle pointer layer" },
  { "left", "H", "Move left" },
  { "down", "J", "Move down" },
  { "up", "K", "Move up" },
  { "right", "L", "Move right" },
  { "counterclockwise", "Q", "Steer counterclockwise" },
  { "clockwise", "E", "Steer clockwise" },
  { "click", "Space", "Left click" },
  { "drag", "D", "Toggle left drag" },
  { "fineKey", "1", "Select fine speed" },
  { "normalKey", "2", "Select normal speed" },
  { "fastKey", "3", "Select fast speed" },
  { "exit", "Escape", "Exit pointer layer" },
}
for _, control in CONTROLS do
  schema[control[1]] =
    UI.Keybind(control[2], { label = control[3], tab = "Keys" })
end
local cfg = UI.Schema(schema)
local allowed = { Space = true, Escape = true, CapsLock = true, Menu = true }
for n = 1, 12 do
  allowed["F" .. n] = true
end
for n = 65, 90 do
  allowed[string.char(n)] = true
end
for n = 0, 9 do
  allowed[tostring(n)] = true
end
local keys, used = {}, {}
for _, control in CONTROLS do
  local key = cfg[control[1]]
  if not allowed[key] or used[key] then
    error(
      "Choose distinct control keys: letters, digits, F1 to F12, Space, Escape, CapsLock or Menu."
    )
  end
  keys[control[1]], used[key] = key, true
end

local active, dragging = false, false
local held, movement = {}, {}
local speed = "normal"
local angle, remainderX, remainderY = 0, 0, 0
local freshTick = true
local invalidNotified = false

local function finite(value, low, high)
  return type(value) == "number"
    and value == value
    and value >= low
    and value <= high
end

local function modified()
  for _, key in
    { "LCtrl", "RCtrl", "LAlt", "RAlt", "LShift", "RShift", "LWin", "RWin" }
  do
    if Input.IsDown(key) then
      return true
    end
  end
  return false
end

local function releaseDrag()
  if dragging then
    HID.Up("Mouse1")
    dragging = false
  end
end

local function stop()
  releaseDrag()
  active = false
  movement = {}
  angle, remainderX, remainderY = 0, 0, 0
  freshTick = true
end

local function valid()
  local ok = finite(cfg.fine, 10, 3000)
    and finite(cfg.normal, 10, 3000)
    and finite(cfg.fast, 10, 3000)
    and finite(cfg.turn, 15, 360)
  if not ok then
    stop()
    if not invalidNotified then
      UI.Notify(
        "Keyboard Compass stopped. Set movement speeds between 10 and 3000 counts/s and steering between 15 and 360 degrees/s.",
        "warning"
      )
      invalidNotified = true
    end
  else
    invalidNotified = false
  end
  return ok
end

for _, control in CONTROLS do
  local action, key = control[1], keys[control[1]]
  Bind(key, {
    when = function()
      return held[key]
        or (
          (active or action == "toggle")
          and (action == "exit" or not modified())
        )
    end,
    action = function()
      if held[key] then
        return nil
      end
      held[key] = true
      if action == "exit" then
        stop()
      elseif action == "toggle" then
        if active then
          stop()
        elseif valid() then
          active = true
          freshTick = true
        end
        UI.Notify(
          if active
            then "Keyboard pointer layer on."
            else "Keyboard pointer layer off.",
          "info"
        )
      elseif action == "fineKey" then
        speed = "fine"
      elseif action == "normalKey" then
        speed = "normal"
      elseif action == "fastKey" then
        speed = "fast"
      elseif action == "click" or action == "drag" then
        if modified() or Input.IsDown("Mouse1") then
          return nil
        end
        if dragging then
          releaseDrag()
        elseif action == "click" then
          HID.Combo("Mouse1")
        else
          HID.Down("Mouse1")
          dragging = true
        end
      else
        movement[action] = true
      end
      return nil
    end,
    release = function()
      held[key] = nil
      movement[action] = nil
    end,
  })
end

function OnTick(dt)
  -- Clear missed releases without interpreting them as clicks or new presses.
  for _, control in CONTROLS do
    local key = keys[control[1]]
    if held[key] and not Input.IsDown(key) then
      held[key], movement[control[1]] = nil, nil
    end
  end
  if not active or not valid() then
    return
  end
  if modified() then
    stop()
    return
  end
  if freshTick then
    freshTick = false
    return
  end
  if not finite(dt, 0, 1000000) then
    stop()
    return
  end
  local x = (if movement.right then 1 else 0) - (if movement.left then 1 else 0)
  local y = (if movement.down then 1 else 0) - (if movement.up then 1 else 0)
  if x == 0 and y == 0 then
    angle, remainderX, remainderY = 0, 0, 0
    return
  end
  local length = math.sqrt(x * x + y * y)
  x, y = x / length, y / length
  -- Drop excess elapsed time after a stall rather than jumping to catch up.
  local seconds = math.min(dt, 50) / 1000
  local turn = (if movement.clockwise then 1 else 0)
    - (if movement.counterclockwise then 1 else 0)
  local omega = turn * cfg.turn * math.pi / 180
  local c, s
  if turn == 0 then
    c, s = math.cos(angle) * seconds, math.sin(angle) * seconds
  else
    local nextAngle = angle + omega * seconds
    -- Integrate a constant steering rate so equal elapsed time gives equal arcs.
    c = (math.sin(nextAngle) - math.sin(angle)) / omega
    s = (math.cos(angle) - math.cos(nextAngle)) / omega
    angle = nextAngle % (2 * math.pi)
  end
  local exactX = cfg[speed] * (x * c - y * s) + remainderX
  local exactY = cfg[speed] * (x * s + y * c) + remainderY
  local outX = if exactX >= 0
    then math.floor(exactX + 0.5)
    else math.ceil(exactX - 0.5)
  local outY = if exactY >= 0
    then math.floor(exactY + 0.5)
    else math.ceil(exactY - 0.5)
  remainderX, remainderY = exactX - outX, exactY - outY
  if outX ~= 0 or outY ~= 0 then
    HID.Move(outX, outY)
  end
end

function OnBlur()
  stop()
end
function OnFocus()
  stop()
end
function OnStop()
  stop()
end
function OnError(message)
  stop()
  Log.Error(message)
end