marketplace/mouse

Keyboard Compass

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

@rebindv0.1.0free

More install options
$rebind install @rebind/keyboard-compass

Gallery

Keyboard Compass preview

Readme

Move, steer, click and drag the pointer from a keyboard layer. The layer starts off so keys type normally until you activate it.

Controls

Default keyAction while the layer is on
F8Toggle the pointer layer. Also activates it while off.
H / J / K / LMove left / down / up / right.
Q / ESteer counterclockwise / clockwise while moving.
1 / 2 / 3Select fine / normal / fast speed.
SpaceLeft click. If dragging, release the drag instead.
DStart or release a left-button drag.
EscapeRelease any owned drag and exit the layer.

All keys are configurable in the settings panel. Use distinct letters, digits, F1 to F12, Space, Escape, CapsLock or Menu. Restart the script after changing keys. Movement and steering settings apply while running.

Opposing directions cancel. Diagonal movement has the same speed as movement along one axis. Hold a direction and Q or E to trace an arc. Releasing the steering key preserves the heading; releasing all movement directions resets it when the next tick observes no direction. Changing a direction changes the input vector within that rotated frame.

Speeds default to 100, 500 and 1200 relative counts per second, with a range of 10 to 3000. Steering defaults to 90 degrees per second, with a range of 15 to 360. Output keeps fractional remainders while moving. After a delayed tick, at most 50 ms of movement is emitted to avoid a large catch-up jump.

Ctrl, Alt, Shift and Cmd/Win shortcuts pass through when starting a control press, except the configured exit key, which still exits and is consumed while the layer is active. If one of those modifiers is held while the layer runs, the next tick exits the layer and releases its drag. A click or new drag is ignored while the physical left button is held. Stopping or deactivating the script releases its owned drag. Repeated key-down events do not toggle controls repeatedly.

Requirements and limits

The package targets Windows, macOS and Linux and can use software output without Rebind Link. See platform support for capture permissions and differences.

Physical mouse movement remains active and combines with generated movement. Relative counts are not guaranteed screen pixels; OS and application behavior affect the visible path. This script owns only the left-button holds it creates. Avoid simultaneous scripts that hold the same button or consume its control keys.

Invalid speed or steering settings stop the layer and release its drag. Correct them before activating it again. Invalid or duplicate control keys prevent startup.

Development status

Source prepared. Package check and lint pass. Mock-runtime tests cover movement, steering, timing, opposing keys, fractional output, dragging and cancellation. Cover and icon exports and the local listing preview have been visually inspected. The art illustrates behavior; it is not a screenshot or execution evidence.

Native behavior and the rendered settings panel remain unverified on every target platform. Listing-preview inspection does not verify either.

rebind check library/keyboard-compass
rebind lint library/keyboard-compass
stylua --check library/keyboard-compass
cargo test -p gp-lua-sdk --features audio --test integration

See the library workflow for deliberate runtime testing and publication.

Reviews

No reviews yet. Write a review in the app.

Source

Version 0.1.0 (current)

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