Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Remapping & layers

Turn any key or button into another key, a chord, a modifier hold, or a whole layer — key remaps, combos, and conditional layers, the same job as AutoHotkey or Karabiner. The logic is identical on Windows, macOS, and Linux, works with any USB keyboard or mouse, and (with a Teensy 4.x attached) comes out as standard USB HID at up to 8,000 Hz. Every recipe below runs unchanged in software mode first.

The building blocks are small and the same throughout:

  • Bind.Remap(from, to) — a declarative one-line key-to-key remap.
  • OnDown(key) / OnUp(key) — return true to let a key pass through, false to swallow it. Send your replacement with HID.Down / HID.Up / HID.Press.
  • Input.IsDown(key) — test whether a physical key is currently held, which is how you build chords, modifier holds, and layers.

Press Left Ctrl + Left Alt + K at any time to stop every running script. (The kill switch uses the left-hand modifiers specifically — right Ctrl / right Alt don’t trigger it.)

Single-key remap

Bind.Remap takes the key you press and the key you want sent in its place — no hooks, no state. Caps Lock becomes Escape; your real Escape is untouched.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=CapsLock to Escape
--]]

Bind.Remap("CapsLock", "Escape")

Bind.Remap returns a handle, so you can toggle a remap at runtime with :disable() and :enable(). Here a hotkey flips it live, and the handle reports its state through .enabled.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Toggleable Remap
--]]

local remap = Bind.Remap("CapsLock", "Escape")

-- Press F8 to switch the remap off and on without restarting the script.
Bind("F8", function()
  if remap.enabled then
    remap:disable()
    UI.Notify("Remap off", "info")
  else
    remap:enable()
    UI.Notify("Remap on", "info")
  end
  return false
end)

For conditions more nuanced than on/off — “only in this app,” “only while another key is held” — drop down to OnDown / OnUp and decide per event. The remaining recipes all use that form.

Chords and combos

A chord fires one action only when several keys are held together. Read the other keys with Input.IsDown at the moment the trigger key goes down, and swallow the trigger so it doesn’t type on its own.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Chord: J+K -> Escape
--]]

-- Press J and K together to send Escape. Either key alone types normally.
function OnDown(key)
  if key == "J" and Input.IsDown("K") then
    HID.Down("Escape")
    HID.Up("Escape")
    return false
  end
  if key == "K" and Input.IsDown("J") then
    HID.Down("Escape")
    HID.Up("Escape")
    return false
  end
  return true
end

Checking both orders makes the chord order-independent. For a combo that includes a real modifier, test it the same way — Input.IsDown("LCtrl") — and emit a keyboard combo with the + syntax:

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Combo: Ctrl+Backspace -> Delete Word
--]]

function OnDown(key)
  if key == "Backspace" and Input.IsDown("LCtrl") then
    -- send Ctrl+Shift+Left then Delete: select the previous word and remove it
    Run(function()
      HID.Press("LCtrl+LShift+Left")
      HID.Press("Delete")
    end)
    return false
  end
  return true
end

Modifier hold

A modifier hold gives one key two jobs: tap it for its normal value, hold it to change what other keys do. Track when the key went down and whether another key was used while it was held. On release, if nothing else fired and the hold was short, send the tap.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Space as Hold-Modifier
--]]

-- Tap Space -> Space. Hold Space + H/J/K/L -> arrow keys.
local TAP_MS = 180
local spaceDown = nil
local usedAsMod = false

local arrows = { H = "Left", J = "Down", K = "Up", L = "Right" }

function OnDown(key)
  if key == "Space" then
    spaceDown = System.Time()
    usedAsMod = false
    return false -- hold the Space output until we know tap vs hold
  end

  if spaceDown and arrows[key] then
    usedAsMod = true
    HID.Down(arrows[key])
    HID.Up(arrows[key])
    return false
  end

  return true
end

function OnUp(key)
  if key == "Space" then
    local heldMs = System.Time() - (spaceDown or 0)
    if not usedAsMod and heldMs < TAP_MS then
      HID.Down("Space")
      HID.Up("Space") -- it was a quick tap, emit the real key
    end
    spaceDown = nil
    return false
  end
  return true
end

function OnBlur()
  spaceDown = nil
end

usedAsMod prevents a stray Space when you genuinely used the hold, and TAP_MS keeps a long, lone hold from emitting a late Space. OnBlur clears the state if focus leaves while you’re mid-hold.

Hold-to-access layer

A layer is a modifier hold scaled up: hold one key to expose an alternate set of keys, release it to return to normal. Keep the mappings in a table so adding a key is a one-line edit.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Nav Layer
--]]

-- Hold CapsLock to turn the right hand into a navigation + editing cluster.
local layerKey = "CapsLock"

local layer = {
  H = "Left",
  J = "Down",
  K = "Up",
  L = "Right",
  U = "PageUp",
  D = "PageDown",
  Y = "Home",
  O = "End",
  Backspace = "Delete",
}

function OnDown(key)
  if key == layerKey then
    return false -- swallow the layer key itself
  end

  if Input.IsDown(layerKey) and layer[key] then
    HID.Down(layer[key])
    HID.Up(layer[key])
    return false
  end

  return true
end

Because the layer is gated on Input.IsDown(layerKey), every other key behaves normally the instant you let go. To make it app-aware — active only in one program — wrap the body in a System.Window() check, the same pattern the productivity recipes use:

if Input.IsDown(layerKey) and layer[key] then
  local win = System.Window()
  if win.process:lower():find("code") then
    HID.Down(layer[key])
    HID.Up(layer[key])
    return false
  end
end

Opposing-key resolution (last pressed wins)

For movement keys mapped to opposite directions — left and right, or up and down — holding both at once is ambiguous: the two presses cancel and you stop. One way to resolve it deterministically: when two opposing directions are held at once, the most recently pressed one is active. Release the newer key and the older one, still held, takes over again.

Record the order in which each key of an opposing pair went down, then send only the winner and suppress its opposite.

--[[
  rebind: min_sdk=3.0.0
  rebind: name=Opposing Keys: Last Pressed Wins
--]]

-- Map a pair of opposing keys so the most recently pressed direction is the one
-- that registers while both are held. Here A/D drive Left/Right; swap the codes
-- for your own layout.
local pair = {
  A = { send = "Left", opposite = "D" },
  D = { send = "Right", opposite = "A" },
}

local pressedAt = {} -- key -> timestamp of its most recent press

local function activeOf(k1, k2)
  -- both held: the one pressed most recently wins
  local t1, t2 = pressedAt[k1], pressedAt[k2]
  if t1 and t2 then
    return (t1 >= t2) and k1 or k2
  end
  return t1 and k1 or (t2 and k2 or nil)
end

local function refresh(key)
  local p = pair[key]
  if not p then
    return
  end
  local winner = activeOf(key, p.opposite)

  -- ensure only the winning direction is currently sent
  if winner == key then
    HID.Up(pair[p.opposite].send)
    HID.Down(p.send)
  elseif winner == p.opposite then
    HID.Up(p.send)
    HID.Down(pair[winner].send)
  else
    HID.Up(p.send)
  end
end

function OnDown(key)
  if not pair[key] then
    return true
  end
  pressedAt[key] = System.Time()
  refresh(key)
  return false -- we drive the output; swallow the raw key
end

function OnUp(key)
  if not pair[key] then
    return true
  end
  pressedAt[key] = nil
  HID.Up(pair[key].send)
  -- hand control back to the opposite key if it is still held
  local other = pair[key].opposite
  if Input.IsDown(other) then
    refresh(other)
  end
  return false
end

function OnBlur()
  pressedAt = {}
  HID.Up("Left")
  HID.Up("Right")
end

The behavior is timing-driven: pressedAt stores when each key last went down, activeOf picks the larger timestamp, and refresh makes the live output match. Releasing the newer key calls refresh on the survivor so the older direction resumes with no extra tap. OnBlur releases both outputs if focus changes mid-hold, so a direction can never get stuck on.

Where each recipe runs

CapabilityNamespacesPlatforms
Bind.Remap, OnDown/OnUp, chords, layersBind, HID, InputWindows, macOS, Linux
App-aware layer (win.process check)System.Window()Windows, macOS; limited/absent on Linux

See Platforms for the full capability matrix.