Home Row Navigation

@rebindv0.1.3

Hold Caps Lock and the home row becomes arrows, Home, End and word movement. Shift still selects.

Files

main.luau

-- Home Row Navigation: hold the layer key and hjkl become arrows.
-- The layer key is swallowed, so Caps Lock never toggles while this runs.

local IS_MAC = _REBIND.platform == "macos"
local WORD = if IS_MAC then "LAlt" else "LCtrl"

local LAYOUTS = {
  hjkl = { H = "Left", J = "Down", K = "Up", L = "Right" },
  ijkl = { J = "Left", K = "Down", I = "Up", L = "Right" },
}

local cfg = UI.Schema({
  layerKey = UI.Keybind("CapsLock", { label = "Layer key", tab = "Layout" }),
  layout = UI.Select(
    "hjkl",
    { "hjkl", "ijkl" },
    { label = "Arrow keys", tab = "Layout" }
  ),
  homeKey = UI.Keybind("U", { label = "Start of line", tab = "Layout" }),
  endKey = UI.Keybind("O", { label = "End of line", tab = "Layout" }),
  wordLeftKey = UI.Keybind("N", { label = "Previous word", tab = "Layout" }),
  wordRightKey = UI.Keybind("M", { label = "Next word", tab = "Layout" }),
  repeatDelay = UI.Slider(350, {
    min = 150,
    max = 800,
    step = 10,
    suffix = " ms",
    label = "Repeat delay",
    tab = "Layout",
  }),
  repeatRate = UI.Slider(30, {
    min = 15,
    max = 120,
    step = 5,
    suffix = " ms",
    label = "Repeat interval",
    tab = "Layout",
  }),
  exclude = UI.Text("", {
    label = "Pass through in these apps",
    placeholder = "process names, comma separated",
    tab = "Apps",
  }),
})

local layerHeld = false
-- key name -> repeat timer for every mapped key currently held
local held = {}

local function target(key)
  local arrows = LAYOUTS[cfg.layout] or LAYOUTS.hjkl
  if arrows[key] then
    return arrows[key]
  end
  if key == cfg.homeKey then
    return "Home"
  elseif key == cfg.endKey then
    return "End"
  elseif key == cfg.wordLeftKey then
    return WORD .. "+Left"
  elseif key == cfg.wordRightKey then
    return WORD .. "+Right"
  end
  return nil
end

local function excluded()
  local process = System.Window().process:lower()
  for name in cfg.exclude:gmatch("[^,]+") do
    name = name:gsub("^%s+", ""):gsub("%s+$", ""):lower()
    if name ~= "" and process:find(name, 1, true) then
      return true
    end
  end
  return false
end

-- Synthetic keys never autorepeat, so the layer repeats them itself: one tap
-- now, then taps at the configured interval after the delay until release.
-- Shift held by the user is forwarded as a real key, so a tap under it selects.
local function press(key, chord)
  HID.Combo(chord)
  held[key] = Timer.After(cfg.repeatDelay, function()
    held[key] = Timer.Every(cfg.repeatRate, function()
      HID.Combo(chord)
    end)
  end)
end

local function releaseAll()
  for key, timer in held do
    timer:Cancel()
    held[key] = nil
  end
end

function OnDown(key)
  if key == cfg.layerKey then
    layerHeld = true
    return false
  end
  if held[key] then
    return false
  end
  if not layerHeld then
    return true
  end
  local chord = target(key)
  if chord == nil or excluded() then
    return true
  end
  press(key, chord)
  return false
end

function OnUp(key)
  if key == cfg.layerKey then
    layerHeld = false
    releaseAll()
    return false
  end
  local timer = held[key]
  if timer then
    timer:Cancel()
    held[key] = nil
    return false
  end
  return true
end

function OnStart()
  Log.Info(
    `Hold {cfg.layerKey}: {cfg.layout} arrows, {cfg.homeKey} Home, {cfg.endKey} End, {cfg.wordLeftKey} and {cfg.wordRightKey} move by word.`
  )
end

function OnStop()
  releaseAll()
end

function OnError(message)
  Log.Error(message)
  releaseAll()
end