Marking Gestures

@rebindv0.1.5

Hold a key, move in one of four directions and release to invoke a shortcut.

Files

main.luau

local cfg = UI.Schema({
  layer = UI.Keybind("F6", { label = "Hold for a gesture", tab = "Controls" }),
  cancel = UI.Keybind("Escape", { label = "Cancel gesture", tab = "Controls" }),
  editing = UI.Keybind(
    "1",
    { label = "Select Editing bank", tab = "Controls" }
  ),
  browsing = UI.Keybind(
    "2",
    { label = "Select Browsing bank", tab = "Controls" }
  ),
  media = UI.Keybind("3", { label = "Select Media bank", tab = "Controls" }),
  bank = UI.Select(
    "Editing",
    { "Editing", "Browsing", "Media" },
    { label = "Initial bank", tab = "Gesture" }
  ),
  threshold = UI.Slider(40, {
    min = 5,
    max = 1000,
    step = 5,
    suffix = " counts",
    label = "Minimum stroke",
    tab = "Gesture",
  }),
  dominance = UI.Slider(1.5, {
    min = 1.1,
    max = 3,
    step = 0.1,
    label = "Required axis dominance",
    tab = "Gesture",
  }),
})
local primary = if _REBIND.platform == "macos" then "LWin" else "LCtrl"
local BANKS = {
  Editing = {
    left = primary .. "+Z",
    right = if _REBIND.platform == "macos" then "LWin+LShift+Z" else "LCtrl+Y",
    up = primary .. "+C",
    down = primary .. "+V",
  },
  Browsing = {
    left = if _REBIND.platform == "macos" then "LWin+[" else "LAlt+Left",
    right = if _REBIND.platform == "macos" then "LWin+]" else "LAlt+Right",
    up = "LCtrl+LShift+Tab",
    down = "LCtrl+Tab",
  },
  Media = {
    left = "MediaPrev",
    right = "MediaNext",
    up = "VolumeUp",
    down = "VolumeDown",
  },
}
local keys, used = {}, {}
local allowed = { Escape = true, Space = 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
for _, name in { "layer", "cancel", "editing", "browsing", "media" } do
  local key = cfg[name]
  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[name], used[key] = key, true
end

local held = {}
local stroke = nil
local invalidNotified = false

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

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

local function valid()
  local ok = BANKS[cfg.bank] ~= nil
    and finite(cfg.threshold, 5, 1000)
    and finite(cfg.dominance, 1.1, 3)
  if not ok then
    stroke = nil
    if not invalidNotified then
      UI.Notify(
        "Marking Gestures is inactive. Select a bank, set minimum stroke between 5 and 1000 counts and axis dominance between 1.1 and 3.",
        "warning"
      )
      invalidNotified = true
    end
  else
    invalidNotified = false
  end
  return ok
end

local function sameWindow()
  local win = System.Window()
  return stroke and win.process == stroke.process and win.title == stroke.title
end

local function finish()
  if not stroke then
    return
  end
  local current = stroke
  local allowedToCommit = valid() and sameWindow() and not modified()
  stroke = nil
  if not allowedToCommit then
    return
  end
  local x, y = math.abs(current.x), math.abs(current.y)
  local direction
  if x >= current.threshold and x >= y * current.dominance then
    direction = if current.x < 0 then "left" else "right"
  elseif y >= current.threshold and y >= x * current.dominance then
    direction = if current.y < 0 then "up" else "down"
  end
  if direction then
    HID.Combo(BANKS[current.bank][direction])
  end
end

for _, name in { "layer", "cancel", "editing", "browsing", "media" } do
  local key = keys[name]
  Bind(key, {
    when = function()
      return held[key]
        or (
          if name == "layer" then valid() and not modified() else stroke ~= nil
        )
    end,
    action = function()
      if held[key] then
        return nil
      end
      held[key] = true
      if name == "layer" then
        local win = System.Window()
        stroke = {
          x = 0,
          y = 0,
          bank = cfg.bank,
          threshold = cfg.threshold,
          dominance = cfg.dominance,
          process = win.process,
          title = win.title,
        }
      elseif name == "cancel" then
        stroke = nil
      else
        stroke.bank = if name == "editing"
          then "Editing"
          elseif name == "browsing" then "Browsing"
          else "Media"
        UI.Notify(stroke.bank .. " gesture bank.", "info")
      end
      return nil
    end,
    release = function()
      held[key] = nil
      if name == "layer" then
        finish()
      end
    end,
  })
end

function OnMove(dx, dy)
  if not stroke then
    return true
  end
  if not valid() or modified() or not sameWindow() then
    stroke = nil
    return false
  end
  if not finite(dx, -1000000, 1000000) or not finite(dy, -1000000, 1000000) then
    stroke = nil
    return false
  end
  stroke.x += dx
  stroke.y += dy
  if math.abs(stroke.x) > 1000000 or math.abs(stroke.y) > 1000000 then
    stroke = nil
  end
  return false
end

function OnTick()
  for key in held do
    if not Input.IsDown(key) then
      held[key] = nil
      if key == keys.layer then
        stroke = nil
      end
    end
  end
  if stroke and (not valid() or modified() or not sameWindow()) then
    stroke = nil
  end
end

function OnBlur()
  stroke = nil
end
function OnFocus()
  stroke = nil
end
function OnStop()
  stroke = nil
end
function OnError(message)
  stroke = nil
  Log.Error(message)
end